improvement(self-host): simplify capability setup configuration (#6230)

* feat(self-host): add capability-aware setup

* fix(self-host): preserve capability compatibility

* fix(copilot): honor preview availability server-side

* improvement(self-host): centralize capability resolution

* fix(self-host): preserve integration availability paths

* fix(testing): align capability-aware config mocks

* improvement(self-host): simplify capability setup configuration

* fix(setup): preserve unowned storage overrides

* fix(self-host): reconcile storage and allowlists

* fix(integrations): preserve connect deep links
This commit is contained in:
Theodore Li
2026-08-04 15:47:36 -04:00
committed by GitHub
parent 39c3fe60d9
commit 35fd4ef42f
135 changed files with 11065 additions and 1060 deletions
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bun
import { stripVersionSuffix } from '@sim/utils/string'
/**
* Verifies the registry-free integration catalog matches the executable block
* registry fields that deployment availability depends on.
*/
import { BLOCK_REGISTRY } from '../apps/sim/blocks/registry-maps'
import { AuthMode, type BlockConfig } from '../apps/sim/blocks/types'
import integrationsJson from '../apps/sim/lib/integrations/integrations.json'
type CatalogAuthType = 'oauth' | 'api-key' | 'none'
interface CatalogEntry {
type: string
slug: string
name: string
category: string
integrationType: string
authType: CatalogAuthType
oauthServiceId?: string
}
function resolveAuthType(block: BlockConfig): CatalogAuthType {
if (block.authMode === AuthMode.OAuth) return 'oauth'
if (block.authMode === AuthMode.ApiKey || block.authMode === AuthMode.BotToken) return 'api-key'
if (block.subBlocks.some((subBlock) => subBlock.type === 'oauth-input')) return 'oauth'
if (
block.subBlocks.some((subBlock) => ['apiKey', 'api_key', 'accessToken'].includes(subBlock.id))
) {
return 'api-key'
}
return 'none'
}
function resolveOAuthServiceId(block: BlockConfig): string | undefined {
const serviceIds = new Set(
block.subBlocks
.filter((subBlock) => subBlock.type === 'oauth-input')
.map((subBlock) => subBlock.serviceId)
.filter((serviceId): serviceId is string => Boolean(serviceId))
)
if (serviceIds.size > 1) {
throw new Error(
`Integration block "${block.type}" declares more than one OAuth service ID: ${[...serviceIds].join(', ')}`
)
}
return serviceIds.values().next().value
}
function expectedEntry(block: BlockConfig): CatalogEntry {
if (!block.integrationType) {
throw new Error(`Integration block "${block.type}" is missing integrationType`)
}
const authType = resolveAuthType(block)
const oauthServiceId = authType === 'oauth' ? resolveOAuthServiceId(block) : undefined
if (authType === 'oauth' && !oauthServiceId) {
throw new Error(`OAuth integration block "${block.type}" is missing an OAuth service ID`)
}
return {
type: block.type,
slug: block.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
name: block.name,
category: block.category,
integrationType: block.integrationType,
authType,
...(oauthServiceId ? { oauthServiceId } : {}),
}
}
function verifyIntegrationCatalog(): void {
const expected = Object.values(BLOCK_REGISTRY).filter(
(block) => block.category === 'tools' && !block.hideFromToolbar && !block.preview
)
const expectedBaseTypes = new Map<string, string>()
for (const block of expected) {
const baseType = stripVersionSuffix(block.type)
const existing = expectedBaseTypes.get(baseType)
if (existing) {
throw new Error(
`Visible integration blocks "${existing}" and "${block.type}" share base type "${baseType}"`
)
}
expectedBaseTypes.set(baseType, block.type)
}
const actual = integrationsJson.integrations as readonly CatalogEntry[]
const expectedByType = new Map(expected.map((block) => [block.type, expectedEntry(block)]))
const actualByType = new Map<string, CatalogEntry>()
const actualSlugs = new Set<string>()
for (const entry of actual) {
if (actualByType.has(entry.type)) {
throw new Error(`Generated integration catalog contains duplicate type "${entry.type}"`)
}
if (actualSlugs.has(entry.slug)) {
throw new Error(`Generated integration catalog contains duplicate slug "${entry.slug}"`)
}
actualByType.set(entry.type, entry)
actualSlugs.add(entry.slug)
}
const issues: string[] = []
for (const [type, entry] of expectedByType) {
const generated = actualByType.get(type)
if (!generated) {
issues.push(`missing generated entry for "${type}"`)
continue
}
for (const field of [
'name',
'slug',
'category',
'integrationType',
'authType',
'oauthServiceId',
] as const) {
if (generated[field] !== entry[field]) {
issues.push(`"${type}" has stale ${field}`)
}
}
}
for (const type of actualByType.keys()) {
if (!expectedByType.has(type)) issues.push(`unexpected generated entry for "${type}"`)
}
if (issues.length > 0) {
throw new Error(
`Generated integration catalog is stale:\n- ${issues.join('\n- ')}\nRun \`bun run scripts/generate-docs.ts\` and commit the generated catalog.`
)
}
process.stdout.write(
`Integration deployment metadata is in sync (${actual.length} integrations).\n`
)
}
verifyIntegrationCatalog()
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'bun:test'
import {
defineCapability,
ENV_CAPABILITIES,
envField,
OAUTH_CLIENT_CAPABILITIES,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import {
CAPABILITY_SETUPS,
defineCapabilitySetup,
EMAIL_SETUP,
getOAuthClientSetupFields,
STORAGE_SETUP,
} from './capability-config.ts'
import { getCapabilitySetupOptions } from './capability-setup.ts'
describe('capability setup configuration', () => {
it('maps every runtime capability and provider exactly once', () => {
expect(CAPABILITY_SETUPS.map((setup) => setup.definition.id)).toEqual(
ENV_CAPABILITIES.map((capability) => capability.id)
)
for (const setup of CAPABILITY_SETUPS) {
expect(Object.keys(setup.providers).sort()).toEqual(
setup.definition.providers.map((provider) => provider.id).sort()
)
}
})
it('fails fast when CLI prompts omit a runtime-owned provider field', () => {
const definition = defineCapability({
strategy: 'fallback',
id: 'sample',
label: 'Sample',
providers: [
{
id: 'remote',
label: 'Remote',
activation: { mode: 'any-present', keys: ['REMOTE_KEY'] },
requires: envField('REMOTE_KEY'),
},
],
} as const)
expect(() =>
defineCapabilitySetup(definition, {
label: 'Sample',
message: 'Sample provider?',
actions: {},
providers: { remote: { prompts: [] } },
optionOrder: ['remote'],
} as never)
).toThrow(/missing: REMOTE_KEY/)
expect(() =>
defineCapabilitySetup(definition, {
label: 'Sample',
message: 'Sample provider?',
actions: {},
providers: {
remote: {
env: { MISSPELLED_REMOTE_KEY: 'true' },
prompts: [{ type: 'field', key: 'REMOTE_KEY', input: 'secret' }],
},
},
optionOrder: ['remote'],
})
).toThrow(/unknown: MISSPELLED_REMOTE_KEY/)
})
it('keeps presets out of the generic provider choices', () => {
expect(getCapabilitySetupOptions(EMAIL_SETUP).map((option) => option.id)).not.toContain(
'mailhog'
)
expect(getCapabilitySetupOptions(STORAGE_SETUP).map((option) => option.id)).not.toContain(
's3-compatible'
)
})
it('maps every OAuth runtime field to a CLI input mode in runtime order', () => {
for (const id of Object.keys(OAUTH_CLIENT_CAPABILITIES) as Array<
keyof typeof OAUTH_CLIENT_CAPABILITIES
>) {
expect(getOAuthClientSetupFields(id).map((field) => field.key)).toEqual(
OAUTH_CLIENT_CAPABILITIES[id]
)
}
})
})
+923
View File
@@ -0,0 +1,923 @@
/**
* CLI-only labels, hints, prompts, and actions for runtime deployment capabilities.
* Provider IDs and environment fields are checked against the application catalog at load time.
*
* @packageDocumentation
*/
import {
ASYNC_JOBS_CAPABILITY,
CACHE_CAPABILITY,
type CapabilityDefinition,
EMAIL_CAPABILITY,
ENV_CAPABILITIES,
type EnvCapabilityValues,
getCapabilityFields,
hasEnvCapabilityValue,
OAUTH_CLIENT_CAPABILITIES,
type OAuthClientCapabilityField,
type OAuthClientCapabilityId,
OCR_CAPABILITY,
SANDBOX_CAPABILITY,
STORAGE_CAPABILITY,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
export type SetupHint =
| string
| {
development: string
containerized: string
}
export type SetupCondition =
| { kind: 'present'; key: string }
| { kind: 'truthy'; key: string }
| { kind: 'equals'; key: string; value: string }
| { kind: 'all'; conditions: readonly SetupCondition[] }
| { kind: 'any'; conditions: readonly SetupCondition[] }
| { kind: 'not'; condition: SetupCondition }
export type SetupPromptCondition = SetupCondition | { kind: 'provider-missing-field'; key: string }
export interface SetupFieldPrompt {
type: 'field'
key: string
input: 'text' | 'secret'
message?: string
hint?: string
required?: boolean
defaultValue?: string
validate?: boolean
when?: SetupPromptCondition
}
export interface SetupConfirmPrompt {
type: 'confirm'
key: string
message: string
defaultValue?: boolean
when?: SetupPromptCondition
}
export interface SetupChoiceOption {
id: string
label: string
hint?: string
currentWhen?: SetupCondition
env?: Readonly<Record<string, string>>
prompts?: readonly SetupPrompt[]
}
export interface SetupChoicePrompt {
type: 'choice'
id: string
message: string
options: readonly SetupChoiceOption[]
when?: SetupPromptCondition
}
export type SetupPrompt = SetupFieldPrompt | SetupConfirmPrompt | SetupChoicePrompt
type ProviderId<TDefinition extends CapabilityDefinition> = TDefinition['providers'][number]['id']
export interface ProviderSetupDefinition {
hint?: SetupHint
env?: Readonly<Record<string, string>>
prompts: readonly SetupPrompt[]
currentWhen?: SetupCondition
}
type ProviderSetupMap<TDefinition extends CapabilityDefinition> = {
[TId in ProviderId<TDefinition>]: ProviderSetupDefinition
}
export interface SetupActionDefinition {
label: string
hint?: SetupHint
env?: Readonly<Record<string, string>>
currentWhen?: SetupCondition
}
type DefaultOptionId<TDefinition extends CapabilityDefinition> = TDefinition extends {
defaultProvider: { kind: 'built-in'; id: infer TId extends string }
}
? TId
: never
export interface CapabilitySetupDefinition<
TDefinition extends CapabilityDefinition = CapabilityDefinition,
TActions extends Readonly<Record<string, SetupActionDefinition>> = Readonly<
Record<string, SetupActionDefinition>
>,
> {
definition: TDefinition
label: string
message: string
providers: ProviderSetupMap<TDefinition>
actions: TActions
defaultOption?: { hint?: SetupHint }
optionOrder: readonly (ProviderId<TDefinition> | DefaultOptionId<TDefinition> | keyof TActions)[]
}
function promptKeys(prompts: readonly SetupPrompt[] = []): string[] {
return prompts.flatMap((prompt) => {
if (prompt.type === 'field' || prompt.type === 'confirm') return [prompt.key]
return prompt.options.flatMap((option) => [
...Object.keys(option.env ?? {}),
...promptKeys(option.prompts),
])
})
}
function conditionKeys(condition: SetupPromptCondition | undefined): string[] {
if (!condition) return []
if (
condition.kind === 'present' ||
condition.kind === 'truthy' ||
condition.kind === 'equals' ||
condition.kind === 'provider-missing-field'
) {
return [condition.key]
}
if (condition.kind === 'all' || condition.kind === 'any') {
return condition.conditions.flatMap(conditionKeys)
}
return conditionKeys(condition.condition)
}
function promptConditionKeys(prompts: readonly SetupPrompt[] = []): string[] {
return prompts.flatMap((prompt) => [
...conditionKeys(prompt.when),
...(prompt.type === 'choice'
? prompt.options.flatMap((option) => [
...conditionKeys(option.currentWhen),
...promptConditionKeys(option.prompts),
])
: []),
])
}
function requirementKeys(
requirement: CapabilityDefinition['providers'][number]['requires']
): string[] {
return requirement.type === 'field'
? [requirement.key]
: requirement.requirements.flatMap(requirementKeys)
}
function providerOwnedInputKeys(
provider: CapabilityDefinition['providers'][number]
): readonly string[] {
return [
...requirementKeys(provider.requires),
...(provider.optionalFields ?? []).map((field) => field.key),
...(provider.pairedFields ?? []).flat(),
]
}
function providerOwnedSetupKeys(
provider: CapabilityDefinition['providers'][number]
): readonly string[] {
return [
...providerOwnedInputKeys(provider),
...(provider.activation.mode === 'enabled'
? [provider.activation.key]
: provider.activation.keys),
]
}
export function defineCapabilitySetup<
const TDefinition extends CapabilityDefinition,
const TActions extends Readonly<Record<string, SetupActionDefinition>>,
>(
definition: TDefinition,
setup: Omit<CapabilitySetupDefinition<TDefinition, TActions>, 'definition'>
): CapabilitySetupDefinition<TDefinition, TActions> {
const providerIds = definition.providers.map((provider) => provider.id)
const configuredProviderIds = Object.keys(setup.providers)
const missingProviders = providerIds.filter((id) => !configuredProviderIds.includes(id))
const unknownProviders = configuredProviderIds.filter((id) => !providerIds.includes(id))
if (missingProviders.length > 0 || unknownProviders.length > 0) {
throw new Error(
`Setup ${definition.id} provider mapping drifted (missing: ${missingProviders.join(', ') || 'none'}; unknown: ${unknownProviders.join(', ') || 'none'})`
)
}
for (const provider of definition.providers) {
const providerSetup = (setup.providers as Record<string, ProviderSetupDefinition>)[provider.id]
if (!providerSetup) throw new Error(`Setup ${definition.id} has no provider ${provider.id}`)
const ownedFields = providerOwnedInputKeys(provider)
const setupFields = providerOwnedSetupKeys(provider)
const configuredFields = [
...promptKeys(providerSetup.prompts),
...Object.keys(providerSetup.env ?? {}),
]
const referencedFields = [
...configuredFields,
...conditionKeys(providerSetup.currentWhen),
...promptConditionKeys(providerSetup.prompts),
]
const unknownFields = referencedFields.filter((key) => !setupFields.includes(key))
const missingFields = ownedFields.filter((key) => !configuredFields.includes(key))
if (unknownFields.length > 0 || missingFields.length > 0) {
throw new Error(
`Setup ${definition.id}/${provider.id} field mapping drifted (missing: ${missingFields.join(', ') || 'none'}; unknown: ${unknownFields.join(', ') || 'none'})`
)
}
}
const capabilityFields = getCapabilityFields(definition)
for (const [actionId, action] of Object.entries(setup.actions)) {
const unknownFields = [
...Object.keys(action.env ?? {}),
...conditionKeys(action.currentWhen),
].filter((key) => !capabilityFields.includes(key))
if (unknownFields.length > 0) {
throw new Error(
`Setup ${definition.id}/${actionId} action writes unknown fields: ${unknownFields.join(', ')}`
)
}
}
const optionIds = [
...providerIds,
...Object.keys(setup.actions),
...(definition.strategy === 'selected' && definition.defaultProvider.kind === 'built-in'
? [definition.defaultProvider.id]
: []),
]
if (
setup.optionOrder.length !== optionIds.length ||
setup.optionOrder.some((id) => !optionIds.includes(String(id))) ||
optionIds.some((id) => !setup.optionOrder.includes(id))
) {
throw new Error(`Setup ${definition.id} option order must list every option once`)
}
return { definition, ...setup }
}
export const EMAIL_SETUP = defineCapabilitySetup(EMAIL_CAPABILITY, {
label: 'Email delivery',
message: 'Email sending?',
actions: {
none: {
label: 'None',
hint: 'emails are logged to the console — fine for local',
},
},
providers: {
resend: {
hint: 'paste an API key',
prompts: [
{
type: 'field',
key: 'RESEND_API_KEY',
input: 'secret',
required: true,
},
],
},
ses: {
hint: 'uses the AWS SDK credential chain',
prompts: [
{
type: 'field',
key: 'AWS_SES_REGION',
input: 'text',
required: true,
defaultValue: 'us-east-1',
},
],
},
smtp: {
hint: 'any SMTP relay',
prompts: [
{ type: 'field', key: 'SMTP_HOST', input: 'text', required: true },
{
type: 'field',
key: 'SMTP_PORT',
input: 'text',
required: true,
defaultValue: '587',
validate: true,
},
{
type: 'field',
key: 'SMTP_USER',
input: 'text',
hint: 'leave empty for an unauthenticated relay',
},
{
type: 'field',
key: 'SMTP_PASS',
input: 'secret',
required: true,
when: { kind: 'present', key: 'SMTP_USER' },
},
],
},
azure: {
hint: 'connection string',
prompts: [
{
type: 'field',
key: 'AZURE_ACS_CONNECTION_STRING',
input: 'secret',
required: true,
},
],
},
gmail: {
hint: 'Workspace service account delegation',
prompts: [
{
type: 'field',
key: 'GMAIL_CREDENTIALS_JSON',
input: 'secret',
required: true,
validate: true,
},
{ type: 'field', key: 'GMAIL_SENDER', input: 'text', required: true },
],
},
},
optionOrder: ['none', 'resend', 'ses', 'smtp', 'azure', 'gmail'],
})
export const STORAGE_SETUP = defineCapabilitySetup(STORAGE_CAPABILITY, {
label: 'File storage',
message: 'File storage?',
actions: {},
defaultOption: {
hint: {
development:
'fine for local dev (external-fetch flows like Instagram publish need cloud storage)',
containerized: 'files live in the container — LOST on restart; evaluation only',
},
},
providers: {
azure: {
hint: 'connection string or account name + key',
prompts: [
{
type: 'field',
key: 'AZURE_STORAGE_CONTAINER_NAME',
input: 'text',
required: true,
defaultValue: 'sim-files',
},
{
type: 'choice',
id: 'azure-credentials',
message: 'Azure credentials?',
options: [
{
id: 'connection-string',
label: 'Connection string',
currentWhen: { kind: 'present', key: 'AZURE_CONNECTION_STRING' },
prompts: [
{
type: 'field',
key: 'AZURE_CONNECTION_STRING',
input: 'secret',
required: true,
},
],
},
{
id: 'account-key',
label: 'Account name and key',
currentWhen: {
kind: 'any',
conditions: [
{ kind: 'present', key: 'AZURE_ACCOUNT_NAME' },
{ kind: 'present', key: 'AZURE_ACCOUNT_KEY' },
],
},
prompts: [
{
type: 'field',
key: 'AZURE_ACCOUNT_NAME',
input: 'text',
required: true,
},
{
type: 'field',
key: 'AZURE_ACCOUNT_KEY',
input: 'secret',
required: true,
},
],
},
],
},
],
},
s3: {
hint: 'AWS S3 or an S3-compatible endpoint',
env: { S3_FORCE_PATH_STYLE: 'false' },
prompts: [
{
type: 'field',
key: 'S3_ENDPOINT',
input: 'text',
hint: 'optional for R2, MinIO, B2, or another S3-compatible service',
validate: true,
},
{
type: 'confirm',
key: 'S3_FORCE_PATH_STYLE',
message: 'Force path-style addressing? (required for MinIO/Ceph, not for R2)',
when: { kind: 'present', key: 'S3_ENDPOINT' },
},
{ type: 'field', key: 'AWS_REGION', input: 'text', required: true },
{ type: 'field', key: 'S3_BUCKET_NAME', input: 'text', required: true },
{
type: 'choice',
id: 'aws-credentials',
message: 'AWS credentials?',
options: [
{
id: 'chain',
label: 'Default credential chain',
hint: 'IAM role, IRSA, profile, or other SDK source',
currentWhen: {
kind: 'all',
conditions: [
{ kind: 'present', key: 'S3_BUCKET_NAME' },
{
kind: 'not',
condition: {
kind: 'any',
conditions: [
{ kind: 'present', key: 'AWS_ACCESS_KEY_ID' },
{ kind: 'present', key: 'AWS_SECRET_ACCESS_KEY' },
],
},
},
],
},
},
{
id: 'static',
label: 'Access key and secret',
hint: 'stored in AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY',
currentWhen: {
kind: 'any',
conditions: [
{ kind: 'present', key: 'AWS_ACCESS_KEY_ID' },
{ kind: 'present', key: 'AWS_SECRET_ACCESS_KEY' },
],
},
prompts: [
{
type: 'field',
key: 'AWS_ACCESS_KEY_ID',
input: 'secret',
required: true,
},
{
type: 'field',
key: 'AWS_SECRET_ACCESS_KEY',
input: 'secret',
required: true,
},
],
},
],
},
],
},
gcs: {
hint: 'bucket; credentials via ADC by default',
prompts: [
{
type: 'field',
key: 'GCS_BUCKET_NAME',
input: 'text',
required: true,
},
{
type: 'field',
key: 'GCS_PROJECT_ID',
input: 'text',
hint: 'optional; inferred from credentials or ADC when empty',
},
{
type: 'choice',
id: 'gcs-credentials',
message: 'Google Cloud credentials?',
options: [
{
id: 'adc',
label: 'Application Default Credentials',
hint: 'Workload Identity or GOOGLE_APPLICATION_CREDENTIALS',
currentWhen: {
kind: 'all',
conditions: [
{ kind: 'present', key: 'GCS_BUCKET_NAME' },
{
kind: 'not',
condition: { kind: 'present', key: 'GCS_CREDENTIALS_JSON' },
},
],
},
},
{
id: 'json',
label: 'Inline service account JSON',
hint: 'stored in GCS_CREDENTIALS_JSON',
currentWhen: { kind: 'present', key: 'GCS_CREDENTIALS_JSON' },
prompts: [
{
type: 'field',
key: 'GCS_CREDENTIALS_JSON',
input: 'secret',
required: true,
validate: true,
},
],
},
],
},
],
},
},
optionOrder: ['local', 's3', 'azure', 'gcs'],
})
export const SANDBOX_SETUP = defineCapabilitySetup(SANDBOX_CAPABILITY, {
label: 'Remote sandboxes',
message: 'Remote sandbox provider?',
actions: {
disabled: {
label: 'Disabled',
hint: 'local JavaScript execution only',
env: {
NEXT_PUBLIC_E2B_ENABLED: 'false',
NEXT_PUBLIC_SANDBOX_ENABLED: 'false',
},
currentWhen: {
kind: 'all',
conditions: [
{ kind: 'not', condition: { kind: 'truthy', key: 'E2B_ENABLED' } },
{
kind: 'not',
condition: { kind: 'present', key: 'DAYTONA_API_KEY' },
},
{
kind: 'not',
condition: { kind: 'present', key: 'DAYTONA_SHELL_SNAPSHOT_ID' },
},
],
},
},
},
providers: {
e2b: {
hint: 'remote code interpreter sandboxes',
env: {
NEXT_PUBLIC_E2B_ENABLED: 'true',
NEXT_PUBLIC_SANDBOX_ENABLED: 'true',
},
prompts: [{ type: 'field', key: 'E2B_API_KEY', input: 'secret', required: true }],
currentWhen: { kind: 'truthy', key: 'E2B_ENABLED' },
},
daytona: {
hint: 'remote Daytona sandboxes',
env: {
NEXT_PUBLIC_E2B_ENABLED: 'false',
NEXT_PUBLIC_SANDBOX_ENABLED: 'true',
},
prompts: [
{
type: 'field',
key: 'DAYTONA_API_KEY',
input: 'secret',
required: true,
},
{
type: 'field',
key: 'DAYTONA_SHELL_SNAPSHOT_ID',
input: 'text',
required: true,
validate: true,
},
],
},
},
optionOrder: ['disabled', 'e2b', 'daytona'],
})
export const JOBS_SETUP = defineCapabilitySetup(ASYNC_JOBS_CAPABILITY, {
label: 'Async jobs',
message: 'Async job provider?',
actions: {},
defaultOption: { hint: 'built-in default' },
providers: {
'trigger-dev': {
hint: 'external background jobs',
prompts: [
{
type: 'field',
key: 'TRIGGER_PROJECT_ID',
input: 'text',
required: true,
},
{
type: 'field',
key: 'TRIGGER_SECRET_KEY',
input: 'secret',
required: true,
},
],
},
},
optionOrder: ['database', 'trigger-dev'],
})
export const CACHE_SETUP = defineCapabilitySetup(CACHE_CAPABILITY, {
label: 'Redis cache',
message: 'Cache and realtime coordination?',
actions: {},
defaultOption: { hint: 'built-in default' },
providers: {
redis: {
hint: 'recommended for multiple replicas',
prompts: [
{
type: 'field',
key: 'REDIS_URL',
input: 'text',
required: true,
defaultValue: 'redis://localhost:6379',
validate: true,
},
{
type: 'field',
key: 'REDIS_TLS_SERVERNAME',
input: 'text',
required: true,
when: { kind: 'provider-missing-field', key: 'REDIS_TLS_SERVERNAME' },
},
],
},
},
optionOrder: ['database', 'redis'],
})
export const KNOWLEDGE_SETUP = defineCapabilitySetup(OCR_CAPABILITY, {
label: 'Knowledge and OCR',
message: 'PDF OCR provider?',
actions: {},
defaultOption: { hint: 'built-in default' },
providers: {
'azure-mistral': {
hint: 'Azure model deployment',
prompts: [
{
type: 'field',
key: 'OCR_AZURE_ENDPOINT',
input: 'text',
required: true,
validate: true,
},
{
type: 'field',
key: 'OCR_AZURE_MODEL_NAME',
input: 'text',
required: true,
},
{
type: 'field',
key: 'OCR_AZURE_API_KEY',
input: 'secret',
required: true,
},
],
},
mistral: {
hint: 'Mistral API key',
prompts: [
{
type: 'field',
key: 'MISTRAL_API_KEY',
input: 'secret',
required: true,
},
],
},
},
optionOrder: ['local', 'mistral', 'azure-mistral'],
})
export const CAPABILITY_SETUPS = [
EMAIL_SETUP,
STORAGE_SETUP,
SANDBOX_SETUP,
JOBS_SETUP,
CACHE_SETUP,
KNOWLEDGE_SETUP,
] as const
const configuredCapabilityIds = new Set(CAPABILITY_SETUPS.map((setup) => setup.definition.id))
const missingCapabilitySetups = ENV_CAPABILITIES.filter(
(capability) => !configuredCapabilityIds.has(capability.id)
)
if (missingCapabilitySetups.length > 0) {
throw new Error(
`Missing CLI setup for capabilities: ${missingCapabilitySetups.map((capability) => capability.id).join(', ')}`
)
}
export type CapabilitySetupId = (typeof CAPABILITY_SETUPS)[number]['definition']['id']
export type SetupFeatureId = CapabilitySetupId | 'llm' | 'integration'
export const SETUP_FEATURES: readonly { id: SetupFeatureId; label: string }[] = [
...CAPABILITY_SETUPS.map((setup) => ({
id: setup.definition.id,
label: setup.label,
})),
{ id: 'llm', label: 'LLM API keys' },
{ id: 'integration', label: 'OAuth integration' },
]
export function getCapabilitySetup(id: string): CapabilitySetupDefinition | null {
return CAPABILITY_SETUPS.find((setup) => setup.definition.id === id) ?? null
}
export function getSetupCommand(id: string): string {
return `bun run setup ${id}`
}
type OAuthClientSetupFields = {
[TId in OAuthClientCapabilityId]: Record<
OAuthClientCapabilityField<TId>,
{ input: 'text' | 'secret' }
>
}
export const OAUTH_CLIENT_SETUP_FIELDS = {
google: {
GOOGLE_CLIENT_ID: { input: 'text' },
GOOGLE_CLIENT_SECRET: { input: 'secret' },
},
x: { X_CLIENT_ID: { input: 'text' }, X_CLIENT_SECRET: { input: 'secret' } },
tiktok: {
TIKTOK_CLIENT_ID: { input: 'text' },
TIKTOK_CLIENT_SECRET: { input: 'secret' },
},
confluence: {
CONFLUENCE_CLIENT_ID: { input: 'text' },
CONFLUENCE_CLIENT_SECRET: { input: 'secret' },
},
jira: {
JIRA_CLIENT_ID: { input: 'text' },
JIRA_CLIENT_SECRET: { input: 'secret' },
},
calcom: { CALCOM_CLIENT_ID: { input: 'text' } },
airtable: {
AIRTABLE_CLIENT_ID: { input: 'text' },
AIRTABLE_CLIENT_SECRET: { input: 'secret' },
},
notion: {
NOTION_CLIENT_ID: { input: 'text' },
NOTION_CLIENT_SECRET: { input: 'secret' },
},
microsoft: {
MICROSOFT_CLIENT_ID: { input: 'text' },
MICROSOFT_CLIENT_SECRET: { input: 'secret' },
},
clickup: {
CLICKUP_CLIENT_ID: { input: 'text' },
CLICKUP_CLIENT_SECRET: { input: 'secret' },
},
linear: {
LINEAR_CLIENT_ID: { input: 'text' },
LINEAR_CLIENT_SECRET: { input: 'secret' },
},
attio: {
ATTIO_CLIENT_ID: { input: 'text' },
ATTIO_CLIENT_SECRET: { input: 'secret' },
},
box: {
BOX_CLIENT_ID: { input: 'text' },
BOX_CLIENT_SECRET: { input: 'secret' },
},
docusign: {
DOCUSIGN_CLIENT_ID: { input: 'text' },
DOCUSIGN_CLIENT_SECRET: { input: 'secret' },
},
dropbox: {
DROPBOX_CLIENT_ID: { input: 'text' },
DROPBOX_CLIENT_SECRET: { input: 'secret' },
},
slack: {
SLACK_CLIENT_ID: { input: 'text' },
SLACK_CLIENT_SECRET: { input: 'secret' },
},
reddit: {
REDDIT_CLIENT_ID: { input: 'text' },
REDDIT_CLIENT_SECRET: { input: 'secret' },
},
wealthbox: {
WEALTHBOX_CLIENT_ID: { input: 'text' },
WEALTHBOX_CLIENT_SECRET: { input: 'secret' },
},
webflow: {
WEBFLOW_CLIENT_ID: { input: 'text' },
WEBFLOW_CLIENT_SECRET: { input: 'secret' },
},
asana: {
ASANA_CLIENT_ID: { input: 'text' },
ASANA_CLIENT_SECRET: { input: 'secret' },
},
pipedrive: {
PIPEDRIVE_CLIENT_ID: { input: 'text' },
PIPEDRIVE_CLIENT_SECRET: { input: 'secret' },
},
hubspot: {
HUBSPOT_CLIENT_ID: { input: 'text' },
HUBSPOT_CLIENT_SECRET: { input: 'secret' },
},
linkedin: {
LINKEDIN_CLIENT_ID: { input: 'text' },
LINKEDIN_CLIENT_SECRET: { input: 'secret' },
},
instagram: {
INSTAGRAM_CLIENT_ID: { input: 'text' },
INSTAGRAM_CLIENT_SECRET: { input: 'secret' },
},
salesforce: {
SALESFORCE_CLIENT_ID: { input: 'text' },
SALESFORCE_CLIENT_SECRET: { input: 'secret' },
},
shopify: {
SHOPIFY_CLIENT_ID: { input: 'text' },
SHOPIFY_CLIENT_SECRET: { input: 'secret' },
},
zoom: {
ZOOM_CLIENT_ID: { input: 'text' },
ZOOM_CLIENT_SECRET: { input: 'secret' },
},
wordpress: {
WORDPRESS_CLIENT_ID: { input: 'text' },
WORDPRESS_CLIENT_SECRET: { input: 'secret' },
},
spotify: {
SPOTIFY_CLIENT_ID: { input: 'text' },
SPOTIFY_CLIENT_SECRET: { input: 'secret' },
},
monday: {
MONDAY_CLIENT_ID: { input: 'text' },
MONDAY_CLIENT_SECRET: { input: 'secret' },
},
trello: { TRELLO_API_KEY: { input: 'secret' } },
'zoho-desk': {
ZOHO_CLIENT_ID: { input: 'text' },
ZOHO_CLIENT_SECRET: { input: 'secret' },
},
} satisfies OAuthClientSetupFields
export function getOAuthClientSetupFields(
id: OAuthClientCapabilityId
): readonly { key: string; input: 'text' | 'secret' }[] {
const runtimeFields = OAUTH_CLIENT_CAPABILITIES[id] as readonly string[]
const setupFields = OAUTH_CLIENT_SETUP_FIELDS[id] as Record<string, { input: 'text' | 'secret' }>
const unknownFields = Object.keys(setupFields).filter((key) => !runtimeFields.includes(key))
const missingFields = runtimeFields.filter((key) => !Object.hasOwn(setupFields, key))
if (unknownFields.length > 0 || missingFields.length > 0) {
throw new Error(
`OAuth setup ${id} field mapping drifted (missing: ${missingFields.join(', ') || 'none'}; unknown: ${unknownFields.join(', ') || 'none'})`
)
}
return runtimeFields.map((key) => ({ key, input: setupFields[key].input }))
}
export function matchesSetupCondition(
condition: SetupCondition,
values: EnvCapabilityValues
): boolean {
if (condition.kind === 'present') return hasEnvCapabilityValue(values, condition.key)
if (condition.kind === 'truthy') {
const value =
values instanceof Map
? values.get(condition.key)
: (values as Readonly<Record<string, unknown>>)[condition.key]
return value === true || value === 1 || String(value).toLowerCase() === 'true'
}
if (condition.kind === 'equals') {
const value =
values instanceof Map
? values.get(condition.key)
: (values as Readonly<Record<string, unknown>>)[condition.key]
return hasEnvCapabilityValue(values, condition.key) && String(value).trim() === condition.value
}
if (condition.kind === 'all') {
return condition.conditions.every((child) => matchesSetupCondition(child, values))
}
if (condition.kind === 'any') {
return condition.conditions.some((child) => matchesSetupCondition(child, values))
}
return !matchesSetupCondition(condition.condition, values)
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'bun:test'
import type { SetupFieldPrompt } from './capability-config.ts'
import { formatCapabilitySetupFieldMessage, markCurrentlyUsed } from './capability-setup.ts'
describe('capability setup presentation', () => {
it('marks the effective option as currently used without replacing its hint', () => {
expect(markCurrentlyUsed('paste an API key', true)).toBe('paste an API key · Currently used')
expect(markCurrentlyUsed(undefined, true)).toBe('Currently used')
expect(markCurrentlyUsed('paste an API key', false)).toBe('paste an API key')
})
it('marks existing fields and explains how secrets are preserved', () => {
const prompt: SetupFieldPrompt = {
type: 'field',
key: 'RESEND_API_KEY',
input: 'secret',
}
expect(formatCapabilitySetupFieldMessage(prompt, true, true)).toBe(
'RESEND_API_KEY (Currently used); leave empty to keep it'
)
expect(formatCapabilitySetupFieldMessage(prompt, false, true)).toBe('RESEND_API_KEY')
})
})
+508
View File
@@ -0,0 +1,508 @@
/**
* Generic prompt rendering and environment transitions for the CLI setup catalog.
*
* @packageDocumentation
*/
import {
EnvCapabilityConfigurationError,
type EnvCapabilityValue,
type EnvCapabilityValues,
getProviderFields,
hasEnvCapabilityValue,
inspectCapability,
isTruthyEnvCapabilityValue,
validateCapabilityFieldInput,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import {
type CapabilitySetupDefinition,
matchesSetupCondition,
type SetupCondition,
type SetupHint,
type SetupPrompt,
} from './capability-config.ts'
import * as p from './prompter.ts'
export interface CapabilitySetupContext {
containerized: boolean
}
export interface EnvCapabilitySetupTransition {
values: Record<string, string>
remove: readonly string[]
}
interface ResolvedSetupOption {
id: string
label: string
hint?: SetupHint
kind: 'provider' | 'default' | 'action'
providerId?: string
env: Readonly<Record<string, string>>
prompts: readonly SetupPrompt[]
currentWhen?: SetupCondition
}
interface PromptState {
setup: CapabilitySetupDefinition
optionId: string
currentValues: ReadonlyMap<string, string>
values: Record<string, string>
}
/** Stages a capability transition into a larger setup run without losing prompt context. */
export function stageCapabilitySetupTransition(
currentValues: Map<string, string>,
values: Record<string, string>,
remove: Set<string>,
transition: EnvCapabilitySetupTransition
): void {
for (const key of transition.remove) {
currentValues.delete(key)
Reflect.deleteProperty(values, key)
remove.add(key)
}
for (const [key, value] of Object.entries(transition.values)) {
currentValues.set(key, value)
values[key] = value
remove.delete(key)
}
}
function resolveHint(
hint: SetupHint | undefined,
context: CapabilitySetupContext
): string | undefined {
if (!hint || typeof hint === 'string') return hint
return context.containerized ? hint.containerized : hint.development
}
/** Adds the standard status marker without discarding an option's explanatory hint. */
export function markCurrentlyUsed(hint: string | undefined, current: boolean): string | undefined {
if (!current) return hint
return hint ? `${hint} · Currently used` : 'Currently used'
}
function promptKeys(prompts: readonly SetupPrompt[] = []): string[] {
return prompts.flatMap((prompt) => {
if (prompt.type === 'field' || prompt.type === 'confirm') return [prompt.key]
return prompt.options.flatMap((option) => [
...Object.keys(option.env ?? {}),
...promptKeys(option.prompts),
])
})
}
function providerSetupFields(
setup: CapabilitySetupDefinition,
providerId: string
): readonly string[] {
const provider = setup.definition.providers.find((candidate) => candidate.id === providerId)
if (!provider) throw new Error(`Capability ${setup.definition.id} has no provider ${providerId}`)
const providerSetup = setup.providers[providerId]
if (!providerSetup) throw new Error(`Setup ${setup.definition.id} has no provider ${providerId}`)
return [
...(provider.activation.mode === 'enabled' ? [provider.activation.key] : []),
...Object.keys(providerSetup.env ?? {}),
...promptKeys(providerSetup.prompts),
]
}
/** Returns fields the setup flow writes or prompts for, including inferred selectors and flags. */
export function getCapabilitySetupFields(setup: CapabilitySetupDefinition): readonly string[] {
return [
...new Set([
...(setup.definition.strategy === 'selected' && setup.definition.selectorKey
? [setup.definition.selectorKey]
: []),
...setup.definition.providers.flatMap((provider) =>
provider.activation.mode === 'enabled' ? [provider.activation.key] : []
),
...Object.entries(setup.providers).flatMap(([providerId]) =>
providerSetupFields(setup, providerId)
),
...Object.values(setup.actions).flatMap((action) => Object.keys(action.env ?? {})),
]),
]
}
/** Expands the runtime providers and CLI-only actions into renderable options. */
export function getCapabilitySetupOptions(
setup: CapabilitySetupDefinition
): readonly ResolvedSetupOption[] {
const definition = setup.definition
const options: ResolvedSetupOption[] = definition.providers.map((provider) => {
const providerSetup = setup.providers[provider.id]
if (!providerSetup) throw new Error(`Setup ${definition.id} has no provider ${provider.id}`)
return {
id: provider.id,
label: provider.label,
hint: providerSetup.hint,
kind: 'provider',
providerId: provider.id,
env: providerSetup.env ?? {},
prompts: providerSetup.prompts,
currentWhen: providerSetup.currentWhen,
}
})
if (definition.strategy === 'selected' && definition.defaultProvider.kind === 'built-in') {
options.push({
id: definition.defaultProvider.id,
label: definition.defaultProvider.label,
hint: setup.defaultOption?.hint,
kind: 'default',
env: {},
prompts: [],
})
}
for (const [id, action] of Object.entries(setup.actions)) {
options.push({
id,
label: action.label,
hint: action.hint,
kind: 'action',
env: action.env ?? {},
prompts: [],
currentWhen: action.currentWhen,
})
}
const byId = new Map(options.map((option) => [option.id, option]))
return setup.optionOrder.map((id) => {
const option = byId.get(String(id))
if (!option) throw new Error(`Setup ${definition.id} option order references ${String(id)}`)
return option
})
}
/** Resolves the setup option representing the effective current configuration. */
export function resolveCurrentCapabilitySetupOptionId(
setup: CapabilitySetupDefinition,
values: EnvCapabilityValues
): string {
const options = getCapabilitySetupOptions(setup)
const explicitAction = options.find(
(option) =>
option.kind === 'action' &&
option.currentWhen &&
matchesSetupCondition(option.currentWhen, values)
)
if (explicitAction) return explicitAction.id
const inspection = inspectCapability(setup.definition, values)
const selectedProviderId =
inspection.strategy === 'selected'
? (inspection.providerId ?? inspection.providers.find((provider) => provider.active)?.id)
: (inspection.providerIds[0] ?? inspection.providers.find((provider) => provider.active)?.id)
if (selectedProviderId) {
const provider = options.find(
(option) =>
option.kind === 'provider' &&
option.providerId === selectedProviderId &&
(!option.currentWhen || matchesSetupCondition(option.currentWhen, values))
)
if (provider) return provider.id
}
if (
setup.definition.strategy === 'selected' &&
setup.definition.defaultProvider.kind === 'built-in'
) {
return setup.definition.defaultProvider.id
}
const firstAction = options.find((option) => option.kind === 'action')
if (firstAction) return firstAction.id
throw new Error(
`Capability ${setup.definition.id} has no setup option for its current configuration`
)
}
/** Applies selector and activation inference to the CLI-entered values. */
export function getCapabilitySetupDraftValues(
setup: CapabilitySetupDefinition,
optionId: string,
promptedValues: Readonly<Record<string, string>>
): Record<string, string> {
const definition = setup.definition
const option = getCapabilitySetupOptions(setup).find((candidate) => candidate.id === optionId)
if (!option) throw new Error(`Capability ${definition.id} has no setup option ${optionId}`)
const values: Record<string, string> = { ...option.env }
if (definition.strategy === 'selected' && definition.selectorKey) {
if (option.providerId) values[definition.selectorKey] = option.providerId
else if (option.kind === 'default' && option.id === definition.defaultProvider.id) {
values[definition.selectorKey] = option.id
}
}
for (const provider of definition.providers) {
if (provider.activation.mode !== 'enabled') continue
values[provider.activation.key] = provider.id === option.providerId ? 'true' : 'false'
}
Object.assign(values, promptedValues)
return values
}
function copyValues(values: EnvCapabilityValues): Record<string, EnvCapabilityValue> {
return values instanceof Map
? Object.fromEntries(values)
: { ...(values as Readonly<Record<string, EnvCapabilityValue>>) }
}
function fieldsToReplace(
setup: CapabilitySetupDefinition,
option: ResolvedSetupOption
): readonly string[] {
if (setup.definition.strategy === 'fallback' && option.providerId) {
return providerSetupFields(setup, option.providerId)
}
const selectedProviderFields = new Set(
setup.definition.providers
.filter((provider) => provider.id === option.providerId)
.flatMap(getProviderFields)
)
const inactiveProviderFields = setup.definition.providers
.filter((provider) => provider.id !== option.providerId)
.flatMap(getProviderFields)
.filter((field) => !selectedProviderFields.has(field))
return [...new Set([...getCapabilitySetupFields(setup), ...inactiveProviderFields])]
}
function proposedCapabilitySetupValues(
setup: CapabilitySetupDefinition,
option: ResolvedSetupOption,
values: Readonly<Record<string, string>>,
currentValues: EnvCapabilityValues
): Record<string, EnvCapabilityValue> {
const proposed = copyValues(currentValues)
for (const key of fieldsToReplace(setup, option)) Reflect.deleteProperty(proposed, key)
Object.assign(proposed, values)
return proposed
}
/** Returns provider requirements discovered after earlier prompts have been answered. */
export function getCapabilitySetupProviderMissingFields(
setup: CapabilitySetupDefinition,
optionId: string,
promptedValues: Readonly<Record<string, string>>,
currentValues: EnvCapabilityValues
): readonly string[] {
const option = getCapabilitySetupOptions(setup).find((candidate) => candidate.id === optionId)
if (!option?.providerId) return []
const values = getCapabilitySetupDraftValues(setup, optionId, promptedValues)
const inspection = inspectCapability(
setup.definition,
proposedCapabilitySetupValues(setup, option, values, currentValues)
)
return (
inspection.providers.find((provider) => provider.id === option.providerId)?.missingFields ?? []
)
}
/** Builds and validates the complete environment transition for one setup option. */
export function buildCapabilitySetupTransition(
setup: CapabilitySetupDefinition,
optionId: string,
promptedValues: Readonly<Record<string, string>>,
currentValues: EnvCapabilityValues
): EnvCapabilitySetupTransition {
const definition = setup.definition
const option = getCapabilitySetupOptions(setup).find((candidate) => candidate.id === optionId)
if (!option) throw new Error(`Capability ${definition.id} has no setup option ${optionId}`)
const values = getCapabilitySetupDraftValues(setup, optionId, promptedValues)
const ownedFields = getCapabilitySetupFields(setup)
const unexpected = Object.keys(values).filter((key) => !ownedFields.includes(key))
if (unexpected.length > 0) {
throw new Error(
`Capability ${definition.id} setup produced unowned fields: ${unexpected.join(', ')}`
)
}
const replacedFields = fieldsToReplace(setup, option)
const remove = replacedFields.filter((key) => !Object.hasOwn(values, key))
const proposed = proposedCapabilitySetupValues(setup, option, values, currentValues)
const inspection = inspectCapability(definition, proposed)
if (inspection.error) throw inspection.error
if (option.providerId) {
const provider = inspection.providers.find((candidate) => candidate.id === option.providerId)
if (!provider || provider.state !== 'ready') {
throw new EnvCapabilityConfigurationError(
definition.id,
`${definition.label} setup option ${option.id} did not configure ${option.providerId}`
)
}
if (inspection.strategy === 'selected' && inspection.providerId !== option.providerId) {
throw new EnvCapabilityConfigurationError(
definition.id,
`${definition.label} setup selected ${inspection.providerId ?? 'nothing'} instead of ${option.providerId}`
)
}
} else {
const activeProvider = inspection.providers.find((provider) => provider.active)
if (activeProvider) {
throw new EnvCapabilityConfigurationError(
definition.id,
`${definition.label} setup option ${option.id} left ${activeProvider.id} active`
)
}
}
return { values, remove }
}
function proposedPromptValues(state: PromptState): EnvCapabilityValues {
const option = getCapabilitySetupOptions(state.setup).find(
(candidate) => candidate.id === state.optionId
)
if (!option) {
throw new Error(`Capability ${state.setup.definition.id} has no setup option ${state.optionId}`)
}
return proposedCapabilitySetupValues(
state.setup,
option,
getCapabilitySetupDraftValues(state.setup, state.optionId, state.values),
state.currentValues
)
}
function shouldRenderPrompt(prompt: SetupPrompt, state: PromptState): boolean {
if (!prompt.when) return true
if (prompt.when.kind === 'provider-missing-field') {
return getCapabilitySetupProviderMissingFields(
state.setup,
state.optionId,
state.values,
state.currentValues
).includes(prompt.when.key)
}
return matchesSetupCondition(prompt.when, proposedPromptValues(state))
}
/** Formats current-value status without ever rendering the configured value. */
export function formatCapabilitySetupFieldMessage(
prompt: Extract<SetupPrompt, { type: 'field' }>,
current: boolean,
keepExisting = false
): string {
const label = prompt.message ?? prompt.key
const status = current ? ' (Currently used)' : ''
const hint = prompt.hint ? ` — ${prompt.hint}` : ''
const preservation = current && keepExisting ? '; leave empty to keep it' : ''
return `${label}${status}${hint}${preservation}`
}
async function promptField(
prompt: Extract<SetupPrompt, { type: 'field' }>,
state: PromptState
): Promise<void> {
const existing = state.currentValues.get(prompt.key)
const current = hasEnvCapabilityValue(state.currentValues, prompt.key)
const draft = getCapabilitySetupDraftValues(state.setup, state.optionId, state.values)
const initialValue = existing ?? draft[prompt.key] ?? prompt.defaultValue
const validate = (value: string): string | undefined => {
if (!value) return prompt.required && !existing ? 'required' : undefined
return prompt.validate
? validateCapabilityFieldInput(state.setup.definition, prompt.key, value)
: undefined
}
let value: string
if (prompt.input === 'secret') {
value = await p.password({
message: formatCapabilitySetupFieldMessage(prompt, current, true),
validate,
})
if (!value && existing) value = existing
} else {
value = await p.text({
message: formatCapabilitySetupFieldMessage(prompt, current),
initialValue,
defaultValue: initialValue ? undefined : prompt.defaultValue,
validate,
})
}
if (value) state.values[prompt.key] = value
else Reflect.deleteProperty(state.values, prompt.key)
}
async function renderPrompts(prompts: readonly SetupPrompt[], state: PromptState): Promise<void> {
for (const prompt of prompts) {
if (!shouldRenderPrompt(prompt, state)) continue
if (prompt.type === 'field') {
await promptField(prompt, state)
continue
}
if (prompt.type === 'confirm') {
const proposed = proposedPromptValues(state)
const current = hasEnvCapabilityValue(state.currentValues, prompt.key)
const enabled = await p.confirm({
message: `${prompt.message}${current ? ' (Currently used)' : ''}`,
initialValue: hasEnvCapabilityValue(proposed, prompt.key)
? isTruthyEnvCapabilityValue(proposed, prompt.key)
: (prompt.defaultValue ?? false),
})
state.values[prompt.key] = enabled ? 'true' : 'false'
continue
}
const currentOption = prompt.options.find(
(option) =>
option.currentWhen && matchesSetupCondition(option.currentWhen, state.currentValues)
)
const initialOption = currentOption ?? prompt.options[0]
if (!initialOption) throw new Error(`Setup choice ${prompt.id} has no options`)
const selectedId = await p.select({
message: prompt.message,
options: prompt.options.map((option) => ({
value: option.id,
label: option.label,
hint: markCurrentlyUsed(option.hint, option.id === currentOption?.id),
})),
initialValue: initialOption.id,
})
const selected = prompt.options.find((option) => option.id === selectedId)
if (!selected) {
throw new Error(`Setup choice ${prompt.id} returned unknown option ${selectedId}`)
}
Object.assign(state.values, selected.env ?? {})
await renderPrompts(selected.prompts ?? [], state)
}
}
/** Renders a CLI-owned capability setup and returns its validated environment transition. */
export async function promptCapabilitySetup(
setup: CapabilitySetupDefinition,
currentValues: ReadonlyMap<string, string>,
context: CapabilitySetupContext
): Promise<EnvCapabilitySetupTransition> {
const options = getCapabilitySetupOptions(setup)
const currentOptionId = resolveCurrentCapabilitySetupOptionId(setup, currentValues)
const selectedOptionId = await p.select({
message: setup.message,
options: options.map((option) => ({
value: option.id,
label: option.label,
hint: markCurrentlyUsed(resolveHint(option.hint, context), option.id === currentOptionId),
})),
initialValue: currentOptionId,
})
const selected = options.find((option) => option.id === selectedOptionId)
if (!selected) {
throw new Error(
`Capability ${setup.definition.id} returned unknown setup option ${selectedOptionId}`
)
}
const state: PromptState = {
setup,
optionId: selected.id,
currentValues,
values: {},
}
await renderPrompts(selected.prompts, state)
return buildCapabilitySetupTransition(setup, selected.id, state.values, currentValues)
}
+271
View File
@@ -0,0 +1,271 @@
import { describe, expect, it } from 'bun:test'
import { OAUTH_CLIENT_CAPABILITIES } from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { buildEnvCapabilityStatus } from './capability-status.ts'
describe('env capability status', () => {
it('reports built-in defaults without treating them as configured services', () => {
const status = buildEnvCapabilityStatus({})
expect(status.features.email).toMatchObject({ state: 'missing', providerIds: [] })
expect(status.features.storage).toMatchObject({ state: 'default', providerId: 'local' })
expect(status.features.sandbox).toMatchObject({ state: 'default', providerId: 'disabled' })
expect(status.features.jobs).toMatchObject({ state: 'default', providerId: 'database' })
expect(status.features.cache).toMatchObject({ state: 'default', providerId: 'database' })
expect(status.features.knowledge).toMatchObject({ state: 'default', providerId: 'local' })
expect(status.features.llm).toMatchObject({
state: 'missing',
configuredPoolCount: 0,
configuredKeyCount: 0,
effectiveKeyCount: 0,
})
expect(status.oauthClients.absentCount).toBe(Object.keys(OAUTH_CLIENT_CAPABILITIES).length)
})
it('reports an explicitly selected but disabled E2B provider as the default', () => {
const status = buildEnvCapabilityStatus({
SANDBOX_PROVIDER: 'e2b',
E2B_ENABLED: 'false',
NEXT_PUBLIC_E2B_ENABLED: 'false',
NEXT_PUBLIC_SANDBOX_ENABLED: 'false',
})
expect(status.features.sandbox).toEqual({
id: 'sandbox',
label: 'Remote sandboxes',
setupCommand: 'bun run setup sandbox',
state: 'default',
providerId: 'disabled',
})
})
it('preserves declared email fallback order', () => {
const status = buildEnvCapabilityStatus({
SMTP_HOST: 'localhost',
SMTP_PORT: '1025',
RESEND_API_KEY: 'secret-resend-key',
GMAIL_CREDENTIALS_JSON: JSON.stringify({
client_email: 'mailer@example.com',
private_key: 'secret-private-key',
}),
GMAIL_SENDER: 'mailer@example.com',
})
expect(status.features.email).toMatchObject({
state: 'configured',
providerIds: ['resend', 'smtp', 'gmail'],
})
expect(JSON.stringify(status)).not.toContain('secret-resend-key')
expect(JSON.stringify(status)).not.toContain('secret-private-key')
})
it('reports selected Daytona and cloud storage providers', () => {
const status = buildEnvCapabilityStatus({
SANDBOX_PROVIDER: 'daytona',
DAYTONA_API_KEY: 'daytona-secret',
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:v1',
NEXT_PUBLIC_SANDBOX_ENABLED: 'true',
STORAGE_PROVIDER: 's3',
AWS_REGION: 'us-east-1',
S3_BUCKET_NAME: 'files',
})
expect(status.features.sandbox).toMatchObject({
state: 'configured',
providerId: 'daytona',
})
expect(status.features.storage).toMatchObject({
state: 'configured',
providerId: 's3',
})
})
it('reports Daytona as missing when its default shell snapshot is absent', () => {
const status = buildEnvCapabilityStatus({
SANDBOX_PROVIDER: 'daytona',
DAYTONA_API_KEY: 'daytona-secret',
NEXT_PUBLIC_SANDBOX_ENABLED: 'true',
})
expect(status.features.sandbox).toMatchObject({
state: 'missing',
providerId: 'daytona',
issue: { state: 'missing' },
})
expect(status.features.sandbox.issue?.message).toContain('DAYTONA_SHELL_SNAPSHOT_ID')
})
it('reports an untagged or floating Daytona shell snapshot as invalid', () => {
const status = buildEnvCapabilityStatus({
SANDBOX_PROVIDER: 'daytona',
DAYTONA_API_KEY: 'daytona-secret',
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:latest',
NEXT_PUBLIC_SANDBOX_ENABLED: 'true',
})
expect(status.features.sandbox).toMatchObject({
state: 'invalid',
providerId: 'daytona',
issue: { state: 'invalid' },
})
expect(status.features.sandbox.issue?.message).toContain('explicit, non-floating name:tag')
})
it('reports remote sandbox server/browser drift as partial', () => {
const status = buildEnvCapabilityStatus({
SANDBOX_PROVIDER: 'daytona',
DAYTONA_API_KEY: 'daytona-secret',
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:v1',
})
expect(status.features.sandbox).toMatchObject({
state: 'partial',
providerId: 'daytona',
issue: { state: 'partial' },
})
expect(status.features.sandbox.issue?.message).toContain('NEXT_PUBLIC_SANDBOX_ENABLED')
})
it('captures partial and invalid entries without aborting the snapshot', () => {
const status = buildEnvCapabilityStatus({
SMTP_HOST: 'localhost',
TRIGGER_DEV_ENABLED: 'true',
TRIGGER_PROJECT_ID: 'project',
REDIS_URL: 'not-a-url',
OCR_AZURE_ENDPOINT: 'https://ocr.example.com',
})
expect(status.features.email).toMatchObject({ state: 'partial' })
expect(
status.features.email.providers.find((provider) => provider.id === 'smtp')
).toMatchObject({
state: 'partial',
missingFields: ['SMTP_PORT'],
})
expect(status.features.jobs).toMatchObject({ state: 'partial', providerId: 'trigger-dev' })
expect(status.features.cache).toMatchObject({ state: 'invalid', providerId: 'redis' })
expect(status.features.knowledge).toMatchObject({
state: 'partial',
providerId: 'azure-mistral',
})
expect(status.features.storage).toMatchObject({ state: 'default', providerId: 'local' })
})
it('does not expose invalid selector values', () => {
const sensitiveValue = 'sensitive-selector-value'
const snapshots = [
buildEnvCapabilityStatus({ STORAGE_PROVIDER: sensitiveValue }),
buildEnvCapabilityStatus({ SANDBOX_PROVIDER: sensitiveValue }),
buildEnvCapabilityStatus({ OCR_PROVIDER: sensitiveValue }),
]
for (const snapshot of snapshots) {
expect(JSON.stringify(snapshot)).not.toContain(sensitiveValue)
}
})
it('reports every OAuth client group as ready, partial, or absent', () => {
const status = buildEnvCapabilityStatus({
GOOGLE_CLIENT_ID: 'google-id',
GOOGLE_CLIENT_SECRET: 'google-secret',
MICROSOFT_CLIENT_ID: 'microsoft-id',
})
expect(status.oauthClients.clients.google).toMatchObject({
state: 'ready',
configuredFieldCount: 2,
requiredFieldCount: 2,
})
expect(status.oauthClients.clients.microsoft).toMatchObject({
state: 'partial',
configuredFieldCount: 1,
missingFields: ['MICROSOFT_CLIENT_SECRET'],
})
expect(status.oauthClients.clients.slack).toMatchObject({
state: 'absent',
configuredFieldCount: 0,
})
expect(status.oauthClients.readyCount).toBe(1)
expect(status.oauthClients.partialCount).toBe(1)
expect(status.oauthClients.absentCount).toBe(Object.keys(OAUTH_CLIENT_CAPABILITIES).length - 2)
})
it('counts configured and effective LLM pool keys without exposing them', () => {
const status = buildEnvCapabilityStatus({
OPENAI_API_KEY_1: 'openai-one',
OPENAI_API_KEY_3: 'openai-three',
FIREWORKS_API_KEY: 'fireworks-fallback',
FIREWORKS_API_KEY_1: 'fireworks-one',
})
expect(status.features.llm).toMatchObject({
state: 'configured',
configuredPoolCount: 2,
configuredKeyCount: 4,
effectiveKeyCount: 3,
})
expect(status.features.llm.pools.openai).toMatchObject({
state: 'configured',
configuredKeyCount: 2,
effectiveKeyCount: 2,
fallbackKeyConfigured: false,
})
expect(status.features.llm.pools.fireworks).toMatchObject({
state: 'configured',
configuredKeyCount: 2,
effectiveKeyCount: 1,
fallbackKeyConfigured: true,
})
expect(JSON.stringify(status)).not.toContain('openai-one')
expect(JSON.stringify(status)).not.toContain('fireworks-fallback')
})
it('counts singular runtime LLM keys as effective pool fallbacks', () => {
const status = buildEnvCapabilityStatus({
OPENAI_API_KEY: 'openai-fallback',
GEMINI_API_KEY: 'gemini-fallback',
COHERE_API_KEY: 'cohere-fallback',
})
expect(status.features.llm).toMatchObject({
state: 'configured',
configuredPoolCount: 3,
configuredKeyCount: 3,
effectiveKeyCount: 3,
})
expect(status.features.llm.pools.openai).toMatchObject({
state: 'configured',
fallbackKeyConfigured: true,
})
expect(status.features.llm.pools.gemini).toMatchObject({
state: 'configured',
fallbackKeyConfigured: true,
})
expect(status.features.llm.pools.cohere).toMatchObject({
state: 'configured',
fallbackKeyConfigured: true,
})
expect(JSON.stringify(status)).not.toContain('openai-fallback')
expect(JSON.stringify(status)).not.toContain('gemini-fallback')
expect(JSON.stringify(status)).not.toContain('cohere-fallback')
})
it('reports non-Redis cache URLs and non-HTTP Azure OCR endpoints as invalid', () => {
const status = buildEnvCapabilityStatus({
REDIS_URL: 'https://cache.example.com',
OCR_PROVIDER: 'azure-mistral',
OCR_AZURE_API_KEY: 'azure-key',
OCR_AZURE_ENDPOINT: 'ftp://ocr.example.com',
OCR_AZURE_MODEL_NAME: 'mistral-ocr',
})
expect(status.features.cache).toMatchObject({ state: 'invalid', providerId: 'redis' })
expect(status.features.cache.issue?.message).toContain('redis:// or rediss://')
expect(status.features.knowledge).toMatchObject({
state: 'invalid',
providerId: 'azure-mistral',
})
expect(status.features.knowledge.issue?.message).toContain(
'OCR_AZURE_ENDPOINT must be a valid HTTP(S) URL'
)
})
})
+481
View File
@@ -0,0 +1,481 @@
/**
* Non-secret deployment-capability status derived from the same definitions the
* application uses at runtime.
*
* @packageDocumentation
*/
import {
ASYNC_JOBS_CAPABILITY,
CACHE_CAPABILITY,
EMAIL_CAPABILITY,
type EnvCapabilityConfigurationError,
type EnvCapabilityValues,
getCapabilityConfigurationError,
hasEnvCapabilityValue,
inspectCapability,
inspectOAuthClientCapability,
isTruthyEnvCapabilityValue,
LLM_KEY_POOLS,
OAUTH_CLIENT_CAPABILITIES,
type OAuthClientCapabilityId,
OCR_CAPABILITY,
type ProviderConfigurationState,
type ProviderInspection,
SANDBOX_CAPABILITY,
STORAGE_CAPABILITY,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { SETUP_FEATURES, type SetupFeatureId } from './capability-config.ts'
export type SetupStatusFeatureId = Exclude<SetupFeatureId, 'integration'>
export type CapabilityStatusState = 'default' | 'configured' | 'missing' | 'partial' | 'invalid'
export interface CapabilityStatusIssue {
state: Extract<CapabilityStatusState, 'missing' | 'partial' | 'invalid'>
message: string
}
interface FeatureStatusBase<TId extends SetupStatusFeatureId> {
id: TId
label: string
setupCommand: `bun run setup ${TId}`
state: CapabilityStatusState
issue?: CapabilityStatusIssue
}
type EmailProviderId = (typeof EMAIL_CAPABILITY.providers)[number]['id']
type StorageProviderId =
| (typeof STORAGE_CAPABILITY)['defaultProvider']['id']
| (typeof STORAGE_CAPABILITY.providers)[number]['id']
type LlmKeyPoolId = keyof typeof LLM_KEY_POOLS
export interface EmailCapabilityStatus extends FeatureStatusBase<'email'> {
strategy: 'fallback'
providerIds: readonly EmailProviderId[]
providers: readonly ProviderInspection<EmailProviderId>[]
}
export interface StorageCapabilityStatus extends FeatureStatusBase<'storage'> {
strategy: 'selected'
providerId: StorageProviderId | null
defaultProviderId: (typeof STORAGE_CAPABILITY)['defaultProvider']['id']
providers: readonly ProviderInspection<(typeof STORAGE_CAPABILITY.providers)[number]['id']>[]
}
export interface SandboxCapabilityStatus extends FeatureStatusBase<'sandbox'> {
providerId: 'disabled' | 'e2b' | 'daytona' | null
}
export interface JobsCapabilityStatus extends FeatureStatusBase<'jobs'> {
providerId: 'database' | 'trigger-dev'
}
export interface CacheCapabilityStatus extends FeatureStatusBase<'cache'> {
providerId: 'database' | 'redis'
}
export interface KnowledgeCapabilityStatus extends FeatureStatusBase<'knowledge'> {
providerId: 'local' | 'mistral' | 'azure-mistral' | null
}
export interface LlmKeyPoolStatus {
id: LlmKeyPoolId
state: 'configured' | 'missing'
configuredKeyCount: number
effectiveKeyCount: number
fallbackKeyConfigured: boolean
}
export interface LlmCapabilityStatus extends FeatureStatusBase<'llm'> {
pools: Readonly<Record<LlmKeyPoolId, LlmKeyPoolStatus>>
configuredPoolCount: number
configuredKeyCount: number
effectiveKeyCount: number
}
interface FeatureStatusById {
email: EmailCapabilityStatus
storage: StorageCapabilityStatus
sandbox: SandboxCapabilityStatus
jobs: JobsCapabilityStatus
cache: CacheCapabilityStatus
knowledge: KnowledgeCapabilityStatus
llm: LlmCapabilityStatus
}
export type EnvCapabilityFeatureStatuses = Pick<FeatureStatusById, SetupStatusFeatureId>
export interface OAuthClientStatus {
id: OAuthClientCapabilityId
state: ProviderConfigurationState
configuredFieldCount: number
requiredFieldCount: number
missingFields: readonly string[]
setupCommand: string
}
export interface OAuthClientStatuses {
clients: Readonly<Record<OAuthClientCapabilityId, OAuthClientStatus>>
readyCount: number
partialCount: number
absentCount: number
invalidCount: number
}
export interface EnvCapabilityStatusSnapshot {
features: EnvCapabilityFeatureStatuses
oauthClients: OAuthClientStatuses
}
function featureMetadata<TId extends SetupStatusFeatureId>(id: TId) {
const definition = SETUP_FEATURES.find((feature) => feature.id === id)
if (!definition) throw new Error(`Missing setup feature definition for ${id}`)
return {
id,
label: definition.label,
setupCommand: `bun run setup ${id}` as const,
}
}
function readConfiguredString(values: EnvCapabilityValues, key: string): string | null {
if (!hasEnvCapabilityValue(values, key)) return null
const value =
values instanceof Map ? values.get(key) : (values as Readonly<Record<string, unknown>>)[key]
return String(value).trim().toLowerCase()
}
function issue(
state: CapabilityStatusIssue['state'],
error: EnvCapabilityConfigurationError,
safeMessage = error.message
): CapabilityStatusIssue {
return { state, message: safeMessage }
}
function brokenProviderState(
providers: readonly ProviderInspection[]
): Extract<CapabilityStatusState, 'partial' | 'invalid'> | null {
if (providers.some((provider) => provider.state === 'invalid')) return 'invalid'
if (providers.some((provider) => provider.state === 'partial')) return 'partial'
return null
}
function inspectEmail(values: EnvCapabilityValues): EmailCapabilityStatus {
const inspection = inspectCapability(EMAIL_CAPABILITY, values)
const brokenState = brokenProviderState(inspection.providers)
const state = inspection.configured ? 'configured' : (brokenState ?? 'missing')
const configurationError =
inspection.error ?? getCapabilityConfigurationError(EMAIL_CAPABILITY, inspection.providers)
return {
...featureMetadata('email'),
strategy: 'fallback',
state,
providerIds: inspection.providerIds,
providers: inspection.providers,
...(configurationError ? { issue: issue(brokenState ?? 'invalid', configurationError) } : {}),
}
}
function inspectStorage(values: EnvCapabilityValues): StorageCapabilityStatus {
const inspection = inspectCapability(STORAGE_CAPABILITY, values)
if (!inspection.error && inspection.providerId) {
const providerId = inspection.providerId as StorageProviderId
return {
...featureMetadata('storage'),
strategy: 'selected',
state: providerId === STORAGE_CAPABILITY.defaultProvider.id ? 'default' : 'configured',
providerId,
defaultProviderId: STORAGE_CAPABILITY.defaultProvider.id,
providers: inspection.providers,
}
}
const selectedId = readConfiguredString(values, STORAGE_CAPABILITY.selectorKey)
const selected = inspection.providers.find((provider) => provider.id === selectedId)
const state =
selected && !selected.active
? 'missing'
: selected?.state === 'partial'
? 'partial'
: selected?.state === 'invalid'
? 'invalid'
: (brokenProviderState(inspection.providers) ?? 'invalid')
const error = inspection.error
if (!error) throw new Error('Storage resolution failed without a configuration error')
const providerId: StorageProviderId | null =
selectedId === STORAGE_CAPABILITY.defaultProvider.id
? STORAGE_CAPABILITY.defaultProvider.id
: (selected?.id ?? null)
const safeMessage =
selectedId && !providerId
? `Unknown ${STORAGE_CAPABILITY.selectorKey}. Expected one of: ${[
STORAGE_CAPABILITY.defaultProvider.id,
...STORAGE_CAPABILITY.providers.map((provider) => provider.id),
].join(', ')}`
: error.message
return {
...featureMetadata('storage'),
strategy: 'selected',
state,
providerId,
defaultProviderId: STORAGE_CAPABILITY.defaultProvider.id,
providers: inspection.providers,
issue: issue(state, error, safeMessage),
}
}
function inspectSandbox(values: EnvCapabilityValues): SandboxCapabilityStatus {
const inspection = inspectCapability(SANDBOX_CAPABILITY, values)
const requestedProvider =
readConfiguredString(values, SANDBOX_CAPABILITY.selectorKey) ??
SANDBOX_CAPABILITY.defaultProvider.id
const providerId =
inspection.providerId === 'e2b' && !isTruthyEnvCapabilityValue(values, 'E2B_ENABLED')
? 'disabled'
: inspection.providerId
if (!inspection.error) {
const coherenceProblems: string[] = []
if (
isTruthyEnvCapabilityValue(values, 'E2B_ENABLED') !==
isTruthyEnvCapabilityValue(values, 'NEXT_PUBLIC_E2B_ENABLED')
) {
coherenceProblems.push('E2B_ENABLED and NEXT_PUBLIC_E2B_ENABLED disagree')
}
const remoteAvailable = providerId !== null && providerId !== 'disabled'
if (remoteAvailable !== isTruthyEnvCapabilityValue(values, 'NEXT_PUBLIC_SANDBOX_ENABLED')) {
coherenceProblems.push('remote sandbox availability and NEXT_PUBLIC_SANDBOX_ENABLED disagree')
}
if (coherenceProblems.length > 0) {
return {
...featureMetadata('sandbox'),
state: 'partial',
providerId,
issue: {
state: 'partial',
message: `${coherenceProblems.join('; ')}. Server and browser configuration must match.`,
},
}
}
return {
...featureMetadata('sandbox'),
state: providerId === 'disabled' ? 'default' : 'configured',
providerId,
}
}
const knownProvider = requestedProvider === 'e2b' || requestedProvider === 'daytona'
const selectedProvider = inspection.providers.find(
(provider) => provider.id === requestedProvider
)
const state = !knownProvider || selectedProvider?.state === 'invalid' ? 'invalid' : 'missing'
return {
...featureMetadata('sandbox'),
state,
providerId: knownProvider ? requestedProvider : null,
issue: issue(
state,
inspection.error,
knownProvider
? inspection.error.message
: 'Unknown SANDBOX_PROVIDER. Expected one of: e2b, daytona'
),
}
}
function inspectJobs(values: EnvCapabilityValues): JobsCapabilityStatus {
const inspection = inspectCapability(ASYNC_JOBS_CAPABILITY, values)
if (!inspection.error && inspection.providerId) {
return {
...featureMetadata('jobs'),
state: inspection.providerId === 'database' ? 'default' : 'configured',
providerId: inspection.providerId,
}
}
if (!inspection.error) {
throw new Error('Async jobs inspection failed without a configuration error')
}
const configuredFieldCount = ['TRIGGER_SECRET_KEY', 'TRIGGER_PROJECT_ID'].filter((key) =>
hasEnvCapabilityValue(values, key)
).length
const state = configuredFieldCount === 0 ? 'missing' : 'partial'
return {
...featureMetadata('jobs'),
state,
providerId: 'trigger-dev',
issue: issue(state, inspection.error),
}
}
function inspectCache(values: EnvCapabilityValues): CacheCapabilityStatus {
const inspection = inspectCapability(CACHE_CAPABILITY, values)
if (!inspection.error && inspection.providerId) {
return {
...featureMetadata('cache'),
state: inspection.providerId === 'database' ? 'default' : 'configured',
providerId: inspection.providerId,
}
}
if (!inspection.error) {
throw new Error('Cache inspection failed without a configuration error')
}
const redis = inspection.providers.find((provider) => provider.id === 'redis')
const state: CapabilityStatusIssue['state'] = redis?.state === 'invalid' ? 'invalid' : 'partial'
return {
...featureMetadata('cache'),
state,
providerId: 'redis',
issue: issue(state, inspection.error),
}
}
function inspectKnowledge(values: EnvCapabilityValues): KnowledgeCapabilityStatus {
const inspection = inspectCapability(OCR_CAPABILITY, values)
if (!inspection.error && inspection.providerId) {
return {
...featureMetadata('knowledge'),
state: inspection.providerId === 'local' ? 'default' : 'configured',
providerId: inspection.providerId,
}
}
if (!inspection.error) {
throw new Error('OCR inspection failed without a configuration error')
}
const selected = readConfiguredString(values, OCR_CAPABILITY.selectorKey)
const azureFields = ['OCR_AZURE_API_KEY', 'OCR_AZURE_ENDPOINT', 'OCR_AZURE_MODEL_NAME'] as const
const azureFieldCount = azureFields.filter((key) => hasEnvCapabilityValue(values, key)).length
const knownProvider =
selected === null ||
selected === 'local' ||
selected === 'mistral' ||
selected === 'azure-mistral'
const resolvedProvider = inspection.providerId
const selectedInspection = inspection.providers.find(
(provider) => provider.id === resolvedProvider
)
const state =
!knownProvider || selectedInspection?.state === 'invalid'
? 'invalid'
: selected === 'mistral' || selected === 'azure-mistral'
? azureFieldCount === 0 && selected === 'azure-mistral'
? 'missing'
: selected === 'mistral' && !hasEnvCapabilityValue(values, 'MISTRAL_API_KEY')
? 'missing'
: 'partial'
: 'partial'
return {
...featureMetadata('knowledge'),
state,
providerId:
selected === 'local' || selected === 'mistral' || selected === 'azure-mistral'
? selected
: selected === null
? resolvedProvider
: null,
issue: issue(
state,
inspection.error,
knownProvider
? inspection.error.message
: 'Unknown OCR_PROVIDER. Expected one of: local, mistral, azure-mistral'
),
}
}
function inspectLlm(values: EnvCapabilityValues): LlmCapabilityStatus {
const pools = {} as Record<LlmKeyPoolId, LlmKeyPoolStatus>
let configuredPoolCount = 0
let configuredKeyCount = 0
let effectiveKeyCount = 0
for (const id of Object.keys(LLM_KEY_POOLS) as LlmKeyPoolId[]) {
const definition = LLM_KEY_POOLS[id]
const rotationKeyCount = definition.keys.filter((key) =>
hasEnvCapabilityValue(values, key)
).length
const fallbackKeyConfigured =
'fallbackKey' in definition && hasEnvCapabilityValue(values, definition.fallbackKey)
const poolConfiguredKeyCount = rotationKeyCount + (fallbackKeyConfigured ? 1 : 0)
const poolEffectiveKeyCount = rotationKeyCount || (fallbackKeyConfigured ? 1 : 0)
const state = poolEffectiveKeyCount > 0 ? 'configured' : 'missing'
if (state === 'configured') configuredPoolCount += 1
configuredKeyCount += poolConfiguredKeyCount
effectiveKeyCount += poolEffectiveKeyCount
pools[id] = {
id,
state,
configuredKeyCount: poolConfiguredKeyCount,
effectiveKeyCount: poolEffectiveKeyCount,
fallbackKeyConfigured,
}
}
return {
...featureMetadata('llm'),
state: configuredPoolCount > 0 ? 'configured' : 'missing',
pools,
configuredPoolCount,
configuredKeyCount,
effectiveKeyCount,
}
}
function inspectOAuthClients(values: EnvCapabilityValues): OAuthClientStatuses {
const clients = {} as Record<OAuthClientCapabilityId, OAuthClientStatus>
let readyCount = 0
let partialCount = 0
let absentCount = 0
let invalidCount = 0
for (const id of Object.keys(OAUTH_CLIENT_CAPABILITIES) as OAuthClientCapabilityId[]) {
const inspection = inspectOAuthClientCapability(id, values)
if (inspection.state === 'ready') readyCount += 1
else if (inspection.state === 'partial') partialCount += 1
else if (inspection.state === 'absent') absentCount += 1
else invalidCount += 1
clients[id] = {
id,
state: inspection.state,
configuredFieldCount: OAUTH_CLIENT_CAPABILITIES[id].length - inspection.missingFields.length,
requiredFieldCount: OAUTH_CLIENT_CAPABILITIES[id].length,
missingFields: inspection.missingFields,
setupCommand: inspection.setupCommand,
}
}
return { clients, readyCount, partialCount, absentCount, invalidCount }
}
const FEATURE_STATUS_BUILDERS = {
email: inspectEmail,
storage: inspectStorage,
sandbox: inspectSandbox,
jobs: inspectJobs,
cache: inspectCache,
knowledge: inspectKnowledge,
llm: inspectLlm,
} satisfies Record<SetupStatusFeatureId, (values: EnvCapabilityValues) => unknown>
/** Builds a status snapshot without returning any configured secret values. */
export function buildEnvCapabilityStatus(values: EnvCapabilityValues): EnvCapabilityStatusSnapshot {
return {
features: {
email: FEATURE_STATUS_BUILDERS.email(values),
storage: FEATURE_STATUS_BUILDERS.storage(values),
sandbox: FEATURE_STATUS_BUILDERS.sandbox(values),
jobs: FEATURE_STATUS_BUILDERS.jobs(values),
cache: FEATURE_STATUS_BUILDERS.cache(values),
knowledge: FEATURE_STATUS_BUILDERS.knowledge(values),
llm: FEATURE_STATUS_BUILDERS.llm(values),
},
oauthClients: inspectOAuthClients(values),
}
}
+133 -85
View File
@@ -1,3 +1,19 @@
import {
ASYNC_JOBS_CAPABILITY,
CACHE_CAPABILITY,
CORE_CONFIGURATION_KEYS,
EMAIL_CAPABILITY,
EnvCapabilityConfigurationError,
hasEnvCapabilityValue,
inspectCapability,
inspectOAuthClientCapability,
OAUTH_CLIENT_CAPABILITIES,
OCR_CAPABILITY,
requireCapability,
SANDBOX_CAPABILITY,
STORAGE_CAPABILITY,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { getSetupCommand } from './capability-config.ts'
import { portOpen } from './detect.ts'
import {
type EnvFile,
@@ -13,7 +29,7 @@ import {
writeEnvValues,
} from './env-files.ts'
import { httpHealth, pgProbe, redisPing } from './probes.ts'
import { FLAG_TWINS, hasMailProvider, LOGIN_PROVIDERS } from './twins.ts'
import { FLAG_TWINS, LOGIN_PROVIDERS } from './twins.ts'
export type CheckGroup = 'files' | 'schema' | 'consistency' | 'coherence' | 'live'
export type CheckStatus = 'pass' | 'warn' | 'fail' | 'skip'
@@ -65,15 +81,10 @@ export function loadCheckContext(live: boolean): CheckContext {
return { env, layout, primary: layout === 'root' ? env.root : env.sim, live }
}
const REQUIRED_KEYS: Partial<Record<EnvTarget, string[]>> = {
sim: [
'DATABASE_URL',
'BETTER_AUTH_SECRET',
'BETTER_AUTH_URL',
'NEXT_PUBLIC_APP_URL',
'ENCRYPTION_KEY',
'INTERNAL_API_SECRET',
],
export const REQUIRED_APP_KEYS = CORE_CONFIGURATION_KEYS
const REQUIRED_KEYS: Partial<Record<EnvTarget, readonly string[]>> = {
sim: REQUIRED_APP_KEYS,
realtime: [
'DATABASE_URL',
'BETTER_AUTH_URL',
@@ -111,7 +122,11 @@ function checkFiles(ctx: CheckContext): Finding[] {
for (const target of layoutTargets(ctx.layout)) {
const file = ctx.env[target]
if (file.exists) {
findings.push({ group: 'files', status: 'pass', message: `${rel(file)} exists` })
findings.push({
group: 'files',
status: 'pass',
message: `${rel(file)} exists`,
})
continue
}
const canSeed = target !== 'sim' && ctx.env.sim.exists
@@ -232,7 +247,13 @@ function checkConsistency(ctx: CheckContext): Finding[] {
// file has nothing to disagree with.
if (ctx.layout !== 'split') {
return ctx.layout === 'root'
? [{ group: 'consistency', status: 'skip', message: 'single .env — nothing to mirror' }]
? [
{
group: 'consistency',
status: 'skip',
message: 'single .env — nothing to mirror',
},
]
: []
}
const findings: Finding[] = []
@@ -281,27 +302,38 @@ function checkCoherence(ctx: CheckContext): Finding[] {
const findings: Finding[] = []
const sim = ctx.primary
if (!sim.exists) return findings
if (isTruthy(sim.vars.get('TRIGGER_DEV_ENABLED'))) {
const missing = ['TRIGGER_SECRET_KEY', 'TRIGGER_PROJECT_ID'].filter((k) => !sim.vars.get(k))
if (missing.length > 0) {
const capabilityChecks = [
{
command: getSetupCommand(ASYNC_JOBS_CAPABILITY.id),
resolve: () => requireCapability(ASYNC_JOBS_CAPABILITY, sim.vars),
},
{
command: getSetupCommand(CACHE_CAPABILITY.id),
resolve: () => requireCapability(CACHE_CAPABILITY, sim.vars),
},
{
command: getSetupCommand(SANDBOX_CAPABILITY.id),
resolve: () => {
const inspection = inspectCapability(SANDBOX_CAPABILITY, sim.vars)
if (inspection.error) throw inspection.error
return inspection
},
},
{
command: getSetupCommand(OCR_CAPABILITY.id),
resolve: () => requireCapability(OCR_CAPABILITY, sim.vars),
},
]
for (const check of capabilityChecks) {
try {
check.resolve()
} catch (error) {
if (!(error instanceof EnvCapabilityConfigurationError)) throw error
findings.push({
group: 'coherence',
status: 'fail',
message: `TRIGGER_DEV_ENABLED is on but ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not set`,
fix: 'set the missing Trigger.dev vars or remove TRIGGER_DEV_ENABLED (jobs fall back to the DB queue)',
})
}
}
const redisUrl = sim.vars.get('REDIS_URL')
if (redisUrl?.startsWith('rediss://')) {
const host = new URL(redisUrl).hostname
if (/^\d+\.\d+\.\d+\.\d+$/.test(host) && !sim.vars.get('REDIS_TLS_SERVERNAME')) {
findings.push({
group: 'coherence',
status: 'fail',
message:
'rediss:// with a bare IP host requires REDIS_TLS_SERVERNAME — the redis client throws without it',
fix: 'set REDIS_TLS_SERVERNAME to the certificate hostname',
message: error.message,
fix: check.command,
})
}
}
@@ -321,50 +353,15 @@ function checkCoherence(ctx: CheckContext): Finding[] {
// schema group already reports the invalid URL
}
}
const hasS3 = Boolean(sim.vars.get('AWS_REGION') && sim.vars.get('S3_BUCKET_NAME'))
const s3Partial = Boolean(sim.vars.get('AWS_REGION')) !== Boolean(sim.vars.get('S3_BUCKET_NAME'))
const hasAzure = Boolean(
sim.vars.get('AZURE_CONNECTION_STRING') || sim.vars.get('AZURE_ACCOUNT_NAME')
)
const azurePartial =
Boolean(sim.vars.get('AZURE_ACCOUNT_NAME')) &&
!sim.vars.get('AZURE_ACCOUNT_KEY') &&
!sim.vars.get('AZURE_CONNECTION_STRING')
const hasGcs = Boolean(sim.vars.get('GCS_BUCKET_NAME'))
if (s3Partial) {
try {
requireCapability(STORAGE_CAPABILITY, sim.vars)
} catch (error) {
if (!(error instanceof EnvCapabilityConfigurationError)) throw error
findings.push({
group: 'coherence',
status: 'fail',
message:
'S3 is half-configured (need BOTH AWS_REGION and S3_BUCKET_NAME) — storage silently falls back to local disk',
fix: 'set the missing var, or remove both to use local disk intentionally',
})
}
if (azurePartial) {
findings.push({
group: 'coherence',
status: 'fail',
message:
'Azure storage is half-configured — AZURE_ACCOUNT_NAME needs AZURE_ACCOUNT_KEY (or use AZURE_CONNECTION_STRING)',
fix: 'set the missing credential, or remove the Azure vars',
})
}
if (hasAzure && hasS3) {
findings.push({
group: 'coherence',
status: 'warn',
message:
'both Azure Blob and S3 are configured — Azure takes precedence, the S3 vars are ignored',
fix: 'remove the backend you are not using',
})
}
if (hasGcs && (hasAzure || hasS3)) {
findings.push({
group: 'coherence',
status: 'warn',
message:
'GCS is configured alongside Azure/S3 — GCS is only used when neither of those is set',
fix: 'remove the backend you are not using',
message: error.message,
fix: getSetupCommand(STORAGE_CAPABILITY.id),
})
}
for (const { server, client } of FLAG_TWINS) {
@@ -389,11 +386,13 @@ function checkCoherence(ctx: CheckContext): Finding[] {
// under E2B_ENABLED or, when SANDBOX_PROVIDER=daytona, DAYTONA_API_KEY. Without
// it the Function block hides its language dropdown and sandbox selector even
// though the server would happily run Python.
const sandboxProvider = (sim.vars.get('SANDBOX_PROVIDER') || 'e2b').toLowerCase()
const sandboxProvider = inspectCapability(SANDBOX_CAPABILITY, sim.vars).providerId
const remoteSandboxAvailable =
sandboxProvider === 'daytona'
? Boolean(sim.vars.get('DAYTONA_API_KEY'))
: isTruthy(sim.vars.get('E2B_ENABLED'))
? hasEnvCapabilityValue(sim.vars, 'DAYTONA_API_KEY')
: sandboxProvider === 'e2b'
? isTruthy(sim.vars.get('E2B_ENABLED'))
: false
if (remoteSandboxAvailable && !isTruthy(sim.vars.get('NEXT_PUBLIC_SANDBOX_ENABLED'))) {
findings.push({
group: 'coherence',
@@ -421,19 +420,43 @@ function checkCoherence(ctx: CheckContext): Finding[] {
})
}
if (isTruthy(sim.vars.get('EMAIL_VERIFICATION_ENABLED')) && !hasMailProvider(sim.vars)) {
const email = inspectCapability(EMAIL_CAPABILITY, sim.vars)
const emailConfigured = email.configured
if (email.error) {
findings.push({
group: 'coherence',
status: 'fail',
message: email.error.message,
fix: getSetupCommand(EMAIL_CAPABILITY.id),
})
}
if (isTruthy(sim.vars.get('EMAIL_VERIFICATION_ENABLED')) && !emailConfigured) {
findings.push({
group: 'coherence',
status: 'fail',
message:
'EMAIL_VERIFICATION_ENABLED is on but no mail provider is configured — verification emails only go to the console, locking out new users',
fix: 'configure RESEND_API_KEY / SMTP_* / AWS_SES_REGION, or turn verification off',
'EMAIL_VERIFICATION_ENABLED is on but no mail provider is configured — the app must bypass verification to avoid locking out new users',
fix: `${getSetupCommand(EMAIL_CAPABILITY.id)}, or turn verification off`,
})
}
for (const providerId of Object.keys(OAUTH_CLIENT_CAPABILITIES)) {
const oauth = inspectOAuthClientCapability(providerId, sim.vars)
if (oauth.state !== 'partial' && oauth.state !== 'invalid') continue
findings.push({
group: 'coherence',
status: 'fail',
message: `${providerId} OAuth is partially configured — missing ${oauth.missingFields.join(', ')}`,
fix: oauth.setupCommand,
})
}
const featureRules: Array<{ flag: string; needs: string[]; label: string }> = [
{ flag: 'BILLING_ENABLED', needs: ['STRIPE_SECRET_KEY'], label: 'billing' },
{ flag: 'E2B_ENABLED', needs: ['E2B_API_KEY'], label: 'E2B code execution' },
{
flag: 'BILLING_ENABLED',
needs: ['STRIPE_SECRET_KEY'],
label: 'billing',
},
{ flag: 'SSO_ENABLED', needs: ['SSO_ISSUER'], label: 'SSO' },
]
for (const rule of featureRules) {
@@ -500,7 +523,11 @@ function checkCoherence(ctx: CheckContext): Finding[] {
}
if (findings.length === 0) {
findings.push({ group: 'coherence', status: 'pass', message: 'no conflicting settings' })
findings.push({
group: 'coherence',
status: 'pass',
message: 'no conflicting settings',
})
}
return findings
}
@@ -525,7 +552,11 @@ async function checkDatabase(sim: EnvFile): Promise<Finding[]> {
fix: 'start Postgres (bun run setup can manage a pgvector container) or fix DATABASE_URL',
})
} else {
findings.push({ group: 'live', status: 'pass', message: 'database reachable' })
findings.push({
group: 'live',
status: 'pass',
message: 'database reachable',
})
if (!probe.pgvectorAvailable) {
findings.push({
group: 'live',
@@ -534,7 +565,10 @@ async function checkDatabase(sim: EnvFile): Promise<Finding[]> {
fix: 'use the pgvector/pgvector:pg17 image or install the extension',
})
}
const { applied, journal } = probe.migrations ?? { applied: null, journal: 0 }
const { applied, journal } = probe.migrations ?? {
applied: null,
journal: 0,
}
if (applied === null) {
findings.push({
group: 'live',
@@ -569,8 +603,10 @@ async function checkDatabase(sim: EnvFile): Promise<Finding[]> {
}
async function checkRedis(sim: EnvFile): Promise<Finding[]> {
const inspection = inspectCapability(CACHE_CAPABILITY, sim.vars)
if (inspection.error || inspection.providerId !== 'redis') return []
const redisUrl = sim.vars.get('REDIS_URL')
if (!redisUrl) return []
if (!redisUrl) throw new Error('Redis resolved as ready without REDIS_URL')
const ping = await redisPing(redisUrl)
return [
ping.ok
@@ -586,10 +622,22 @@ async function checkRedis(sim: EnvFile): Promise<Finding[]> {
async function checkService(label: string, port: number, url: string): Promise<Finding[]> {
if (!(await portOpen(port))) {
return [{ group: 'live', status: 'skip', message: `${label}: not running on :${port}` }]
return [
{
group: 'live',
status: 'skip',
message: `${label}: not running on :${port}`,
},
]
}
if (await httpHealth(url)) {
return [{ group: 'live', status: 'pass', message: `${label} healthy on :${port}` }]
return [
{
group: 'live',
status: 'pass',
message: `${label} healthy on :${port}`,
},
]
}
return [
{
+627
View File
@@ -0,0 +1,627 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'bun:test'
import {
type ConfigurationCommandRunner,
type ConfigurationSourceDiscoveryOptions,
discoverConfigurationSources as discoverConfigurationSourcesFromEnvironment,
parseComposeFileEnvironment,
resolveKubernetesContainerEnvironment,
} from './configuration-sources.ts'
const temporaryDirectories: string[] = []
function temporaryDirectory(): string {
const directory = mkdtempSync(path.join(tmpdir(), 'sim-configuration-sources-'))
temporaryDirectories.push(directory)
return directory
}
function commandResult(status: number, stdout = '') {
return { status, stdout, stderr: '' }
}
function discoverConfigurationSources(options: ConfigurationSourceDiscoveryOptions) {
return discoverConfigurationSourcesFromEnvironment({ processEnvironment: {}, ...options })
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
describe('discoverConfigurationSources', () => {
it('enumerates split development and prepared Compose configuration separately', () => {
const root = temporaryDirectory()
mkdirSync(path.join(root, 'apps/sim'), { recursive: true })
mkdirSync(path.join(root, 'apps/realtime'), { recursive: true })
mkdirSync(path.join(root, 'packages/db'), { recursive: true })
writeFileSync(path.join(root, 'apps/sim/.env'), 'RESEND_API_KEY=dev-email\n')
writeFileSync(path.join(root, 'apps/realtime/.env'), 'REDIS_URL=redis://localhost:6379\n')
writeFileSync(path.join(root, 'packages/db/.env'), 'DATABASE_URL=postgresql://dev\n')
writeFileSync(path.join(root, '.env'), 'RESEND_API_KEY=compose-email\n')
writeFileSync(
path.join(root, 'docker-compose.prod.yml'),
`services:
simstudio:
image: ghcr.io/simstudioai/simstudio:latest
env_file:
- .env
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio
- NEXT_PUBLIC_APP_URL=\${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
- BETTER_AUTH_URL=\${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
- REDIS_URL=\${REDIS_URL:-redis://redis:6379}
`
)
const runner: ConfigurationCommandRunner = () => commandResult(1)
const sources = discoverConfigurationSources({ root, runner })
expect(sources).toHaveLength(2)
expect(sources[0]).toMatchObject({ kind: 'dev', managedByCurrentCheckout: true })
expect(sources[0].values?.get('RESEND_API_KEY')).toBe('dev-email')
expect(sources[0].values?.has('DATABASE_URL')).toBe(false)
expect(sources[1]).toMatchObject({ kind: 'compose', managedByCurrentCheckout: false })
expect(sources[1].values?.get('RESEND_API_KEY')).toBe('compose-email')
expect(sources[1].values?.get('REDIS_URL')).toBe('redis://redis:6379')
expect(sources[1].values?.get('DATABASE_URL')).toBe(
'postgresql://postgres:postgres@db:5432/simstudio'
)
})
it('uses the last active value from a prepared Compose .env file', () => {
const root = temporaryDirectory()
writeFileSync(path.join(root, '.env'), 'RESEND_API_KEY=old\nRESEND_API_KEY=current\n')
writeFileSync(
path.join(root, 'docker-compose.prod.yml'),
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n env_file: .env\n'
)
const sources = discoverConfigurationSources({ root, runner: () => commandResult(1) })
expect(sources).toHaveLength(1)
expect(sources[0].values?.get('RESEND_API_KEY')).toBe('current')
})
it('uses the effective environment of a stopped Compose app container', () => {
const parent = temporaryDirectory()
const root = path.join(parent, 'checkout')
const deployment = path.join(parent, 'deployment')
mkdirSync(root)
mkdirSync(deployment)
const composeFile = path.join(deployment, 'compose.yml')
writeFileSync(
composeFile,
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n'
)
const runner: ConfigurationCommandRunner = (command, args) => {
if (command === 'docker' && args[0] === 'info') return commandResult(0)
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
return commandResult(
0,
JSON.stringify([{ Name: 'external-sim', Status: 'exited', ConfigFiles: composeFile }])
)
}
if (command === 'docker' && args[0] === 'ps') return commandResult(0, 'container-id\n')
if (command === 'docker' && args[0] === 'inspect') {
return commandResult(
0,
JSON.stringify([
{
Created: '2026-01-01T00:00:00Z',
State: { Running: false },
Config: { Env: ['RESEND_API_KEY=secret', 'REDIS_URL=redis://redis:6379'] },
},
])
)
}
return commandResult(1)
}
const sources = discoverConfigurationSources({ root, runner })
expect(sources).toHaveLength(1)
expect(sources[0]).toMatchObject({
kind: 'compose',
label: 'Compose project "external-sim"',
managedByCurrentCheckout: false,
})
expect(sources[0].values?.get('REDIS_URL')).toBe('redis://redis:6379')
expect(sources[0].values?.get('RESEND_API_KEY')).toBe('secret')
})
it('replaces the prepared root source with one unambiguous live project', () => {
const root = temporaryDirectory()
writeFileSync(path.join(root, '.env'), 'RESEND_API_KEY=prepared\n')
const composeFile = path.join(root, 'docker-compose.prod.yml')
writeFileSync(
composeFile,
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n'
)
const runner: ConfigurationCommandRunner = (command, args) => {
if (command === 'docker' && args[0] === 'info') return commandResult(0)
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
return commandResult(
0,
JSON.stringify([{ Name: 'current-sim', Status: 'running', ConfigFiles: composeFile }])
)
}
if (command === 'docker' && args[0] === 'ps') return commandResult(0, 'container-id\n')
if (command === 'docker' && args[0] === 'inspect') {
return commandResult(
0,
JSON.stringify([
{
Created: '2026-01-01T00:00:00Z',
State: { Running: true },
Config: { Env: ['RESEND_API_KEY=effective'] },
},
])
)
}
return commandResult(1)
}
const sources = discoverConfigurationSources({ root, runner })
expect(sources).toHaveLength(1)
expect(sources[0]).toMatchObject({
label: 'Compose project "current-sim"',
managedByCurrentCheckout: true,
})
expect(sources[0].values?.get('RESEND_API_KEY')).toBe('effective')
})
it('does not claim a live current-checkout project is setup-managed without root .env', () => {
const root = temporaryDirectory()
const composeFile = path.join(root, 'docker-compose.prod.yml')
writeFileSync(
composeFile,
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n'
)
const runner: ConfigurationCommandRunner = (command, args) => {
if (command === 'docker' && args[0] === 'info') return commandResult(0)
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
return commandResult(
0,
JSON.stringify([{ Name: 'current-sim', Status: 'running', ConfigFiles: composeFile }])
)
}
if (command === 'docker' && args[0] === 'ps') return commandResult(0, 'container-id\n')
if (command === 'docker' && args[0] === 'inspect') {
return commandResult(
0,
JSON.stringify([{ State: { Running: true }, Config: { Env: ['NODE_ENV=production'] } }])
)
}
return commandResult(1)
}
const sources = discoverConfigurationSources({ root, runner })
expect(sources).toHaveLength(1)
expect(sources[0].managedByCurrentCheckout).toBe(false)
})
it('loads development env files with Next precedence', () => {
const root = temporaryDirectory()
const appDirectory = path.join(root, 'apps/sim')
mkdirSync(appDirectory, { recursive: true })
writeFileSync(path.join(appDirectory, '.env'), 'STATUS_PRECEDENCE=base\n')
writeFileSync(path.join(appDirectory, '.env.development'), 'STATUS_PRECEDENCE=development\n')
writeFileSync(path.join(appDirectory, '.env.local'), 'STATUS_PRECEDENCE=local\n')
writeFileSync(
path.join(appDirectory, '.env.development.local'),
'STATUS_PRECEDENCE=development-local\n'
)
const sources = discoverConfigurationSources({ root, runner: () => commandResult(1) })
expect(sources).toHaveLength(1)
expect(sources[0].values?.get('STATUS_PRECEDENCE')).toBe('development-local')
expect(sources[0].location).toContain('.env.development.local')
expect(sources[0].location).toContain('process environment')
expect(sources[0].managedByCurrentCheckout).toBe(false)
})
it('does not offer setup writes when a process-only capability value wins', () => {
const root = temporaryDirectory()
const appDirectory = path.join(root, 'apps/sim')
mkdirSync(appDirectory, { recursive: true })
writeFileSync(path.join(appDirectory, '.env'), 'SLACK_CLIENT_SECRET=file-secret\n')
const sources = discoverConfigurationSources({
root,
runner: () => commandResult(1),
processEnvironment: { SLACK_CLIENT_ID: 'process-client-id' },
})
expect(sources[0].values?.get('SLACK_CLIENT_ID')).toBe('process-client-id')
expect(sources[0].managedByCurrentCheckout).toBe(false)
})
it('reports missing files in the split development configuration', () => {
const root = temporaryDirectory()
const appDirectory = path.join(root, 'apps/sim')
mkdirSync(appDirectory, { recursive: true })
writeFileSync(
path.join(appDirectory, '.env'),
[
'DATABASE_URL=postgresql://localhost/sim',
`BETTER_AUTH_SECRET=${'a'.repeat(32)}`,
'BETTER_AUTH_URL=http://localhost:3000',
'NEXT_PUBLIC_APP_URL=http://localhost:3000',
`ENCRYPTION_KEY=${'b'.repeat(64)}`,
`INTERNAL_API_SECRET=${'c'.repeat(32)}`,
].join('\n')
)
const sources = discoverConfigurationSources({ root, runner: () => commandResult(1) })
expect(sources).toHaveLength(1)
expect(sources[0].configurationIssues).toEqual(
expect.arrayContaining(['apps/realtime/.env is missing', 'packages/db/.env is missing'])
)
})
it('reports shared-value drift across split development env files', () => {
const root = temporaryDirectory()
mkdirSync(path.join(root, 'apps/sim'), { recursive: true })
mkdirSync(path.join(root, 'apps/realtime'), { recursive: true })
mkdirSync(path.join(root, 'packages/db'), { recursive: true })
writeFileSync(
path.join(root, 'apps/sim/.env'),
[
'DATABASE_URL=postgresql://localhost/sim',
`BETTER_AUTH_SECRET=${'a'.repeat(32)}`,
'BETTER_AUTH_URL=http://localhost:3000',
'NEXT_PUBLIC_APP_URL=http://localhost:3000',
`ENCRYPTION_KEY=${'b'.repeat(64)}`,
`INTERNAL_API_SECRET=${'c'.repeat(32)}`,
].join('\n')
)
writeFileSync(
path.join(root, 'apps/realtime/.env'),
[
'DATABASE_URL=postgresql://localhost/sim',
`BETTER_AUTH_SECRET=${'d'.repeat(32)}`,
'BETTER_AUTH_URL=http://localhost:3000',
'NEXT_PUBLIC_APP_URL=http://localhost:3000',
`INTERNAL_API_SECRET=${'c'.repeat(32)}`,
].join('\n')
)
writeFileSync(path.join(root, 'packages/db/.env'), 'DATABASE_URL=postgresql://localhost/other')
const sources = discoverConfigurationSources({ root, runner: () => commandResult(1) })
expect(sources[0].configurationIssues).toEqual(
expect.arrayContaining([
'BETTER_AUTH_SECRET differs between apps/sim/.env and apps/realtime/.env',
'DATABASE_URL differs between apps/sim/.env and packages/db/.env',
])
)
})
it('identifies a Helm release by current context, namespace, and release', () => {
const root = temporaryDirectory()
const runner: ConfigurationCommandRunner = (command, args) => {
if (command === 'helm') {
if (args.includes('-a') || !args.includes('--deployed') || !args.includes('--failed')) {
return commandResult(1)
}
if (args.at(-2) !== '--kube-context' || args.at(-1) !== 'production-cluster') {
return commandResult(1)
}
return commandResult(
0,
JSON.stringify([{ name: 'sim-prod', namespace: 'production', chart: 'sim-1.2.3' }])
)
}
if (command === 'kubectl' && args[0] === 'config') {
return commandResult(0, 'production-cluster\n')
}
if (!args.includes('--context') || !args.includes('production-cluster')) {
return commandResult(1)
}
if (command === 'kubectl' && args[1] === 'deployments') {
return commandResult(
0,
JSON.stringify({
items: [
{
spec: {
template: {
spec: {
containers: [
{
name: 'app',
env: [{ name: 'NEXT_PUBLIC_APP_URL', value: 'https://sim.example.com' }],
},
],
},
},
},
},
],
})
)
}
return commandResult(1)
}
const sources = discoverConfigurationSources({ root, runner })
expect(sources).toHaveLength(1)
expect(sources[0]).toMatchObject({
kind: 'helm',
label: 'Helm release "sim-prod"',
location: 'context production-cluster · namespace production · release sim-prod',
managedByCurrentCheckout: false,
})
expect(sources[0].values?.get('NEXT_PUBLIC_APP_URL')).toBe('https://sim.example.com')
})
it('keeps every Compose config file after one file identifies Sim', () => {
const parent = temporaryDirectory()
const root = path.join(parent, 'checkout')
const deployment = path.join(parent, 'deployment')
mkdirSync(root)
mkdirSync(deployment)
const baseFile = path.join(deployment, 'compose.yml')
const overrideFile = path.join(deployment, 'compose.override.yml')
writeFileSync(
baseFile,
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n'
)
writeFileSync(
overrideFile,
'services:\n simstudio:\n environment:\n - REDIS_URL=redis://override\n'
)
let renderedWithOverride = false
const runner: ConfigurationCommandRunner = (command, args) => {
if (command === 'docker' && args[0] === 'info') return commandResult(0)
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
return commandResult(
0,
JSON.stringify([
{
Name: 'external-sim',
Status: 'running',
ConfigFiles: `${baseFile},${overrideFile}`,
},
])
)
}
if (command === 'docker' && args[0] === 'ps') return commandResult(0)
if (command === 'docker' && args[0] === 'compose' && args.includes('config')) {
renderedWithOverride = args.includes(overrideFile)
return commandResult(
0,
JSON.stringify({
services: { simstudio: { environment: { REDIS_URL: 'redis://override' } } },
})
)
}
return commandResult(1)
}
const sources = discoverConfigurationSources({ root, runner })
expect(renderedWithOverride).toBe(true)
expect(sources[0].location).toContain(overrideFile)
expect(sources[0].values?.get('REDIS_URL')).toBe('redis://override')
})
it('fails fast when Docker Compose returns malformed discovery output', () => {
const root = temporaryDirectory()
const runner: ConfigurationCommandRunner = (command, args) => {
if (command === 'docker' && args[0] === 'info') return commandResult(0)
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
return commandResult(0, 'not-json')
}
return commandResult(1)
}
expect(() => discoverConfigurationSources({ root, runner })).toThrow(
'Docker Compose returned an invalid project list'
)
})
it('fails fast when a Docker Compose project entry changes shape', () => {
const root = temporaryDirectory()
const runner: ConfigurationCommandRunner = (command, args) => {
if (command === 'docker' && args[0] === 'info') return commandResult(0)
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
return commandResult(0, JSON.stringify([{ Name: 'sim-without-config-files' }]))
}
return commandResult(1)
}
expect(() => discoverConfigurationSources({ root, runner })).toThrow(
'Docker Compose returned an invalid project list'
)
})
it('does not treat unrelated Docker tooling as a Sim configuration source', () => {
const root = temporaryDirectory()
const runner: ConfigurationCommandRunner = (command, args) => {
if (command === 'docker' && args[0] === '--version') return commandResult(0)
return commandResult(1)
}
const sources = discoverConfigurationSources({ root, runner })
expect(sources).toHaveLength(0)
})
it('does not treat an unrelated Kubernetes context as a Sim configuration source', () => {
const root = temporaryDirectory()
const runner: ConfigurationCommandRunner = (command, args) => {
if (command === 'kubectl' && args[0] === 'config') {
return commandResult(0, 'production-cluster\n')
}
return commandResult(1)
}
const sources = discoverConfigurationSources({ root, runner })
expect(sources).toHaveLength(0)
})
it('does not substitute desired Compose values when a known container cannot be inspected', () => {
const root = temporaryDirectory()
const composeFile = path.join(root, 'docker-compose.prod.yml')
writeFileSync(
composeFile,
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n'
)
const runner: ConfigurationCommandRunner = (command, args) => {
if (command === 'docker' && args[0] === 'info') return commandResult(0)
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
return commandResult(
0,
JSON.stringify([{ Name: 'known-sim', Status: 'running', ConfigFiles: composeFile }])
)
}
if (command === 'docker' && args[0] === 'ps') return commandResult(0, 'container-id\n')
if (command === 'docker' && args[0] === 'inspect') return commandResult(1)
if (command === 'docker' && args[0] === 'compose') {
return commandResult(
0,
JSON.stringify({
services: { simstudio: { environment: { RESEND_API_KEY: 'desired' } } },
})
)
}
return commandResult(1)
}
const sources = discoverConfigurationSources({ root, runner })
expect(sources).toHaveLength(1)
expect(sources[0].values).toBeNull()
expect(sources[0].warning).toContain('could not be inspected')
})
it('reports a known Compose project as unknown when its containers cannot be enumerated', () => {
const root = temporaryDirectory()
const composeFile = path.join(root, 'docker-compose.prod.yml')
writeFileSync(
composeFile,
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n'
)
const runner: ConfigurationCommandRunner = (command, args) => {
if (command === 'docker' && args[0] === 'info') return commandResult(0)
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
return commandResult(
0,
JSON.stringify([{ Name: 'known-sim', Status: 'running', ConfigFiles: composeFile }])
)
}
if (command === 'docker' && args[0] === 'ps') return commandResult(1)
return commandResult(1)
}
const sources = discoverConfigurationSources({ root, runner })
expect(sources).toHaveLength(1)
expect(sources[0].values).toBeNull()
expect(sources[0].warning).toContain('could not be enumerated')
})
it('fails fast when a Helm release entry changes shape', () => {
const root = temporaryDirectory()
const runner: ConfigurationCommandRunner = (command, args) => {
if (command === 'kubectl' && args[0] === 'config') {
return commandResult(0, 'production-cluster\n')
}
if (command === 'helm' && args[0] === 'list') {
return commandResult(0, JSON.stringify([{ name: 'sim-prod', namespace: 'production' }]))
}
return commandResult(1)
}
expect(() => discoverConfigurationSources({ root, runner })).toThrow(
'Helm returned an invalid release list'
)
})
})
describe('parseComposeFileEnvironment', () => {
it('does not inject root .env when the service has no env_file', () => {
const values = parseComposeFileEnvironment(
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n environment:\n - REDIS_URL=redis://redis:6379\n',
new Map([['RESEND_API_KEY', 'must-not-be-injected']])
)
expect(values?.get('REDIS_URL')).toBe('redis://redis:6379')
expect(values?.has('RESEND_API_KEY')).toBe(false)
})
it('returns unknown for unsupported inline environment syntax', () => {
const values = parseComposeFileEnvironment(
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n env_file: .env\n environment: { RESEND_API_KEY: override }\n',
new Map([['RESEND_API_KEY', 'root-value']])
)
expect(values).toBeNull()
})
})
describe('resolveKubernetesContainerEnvironment', () => {
it('applies envFrom order and then explicit env/valueFrom precedence', () => {
const resources = new Map([
[
'secret/app-secret',
{
data: {
SHARED: Buffer.from('secret').toString('base64'),
SECRET_ONLY: Buffer.from('secret-only').toString('base64'),
EXPLICIT_SOURCE: Buffer.from('from-secret-key').toString('base64'),
},
},
],
['configmap/app-config', { data: { SHARED: 'configmap', CONFIG_ONLY: 'config-only' } }],
])
const resolution = resolveKubernetesContainerEnvironment(
{
envFrom: [{ secretRef: { name: 'app-secret' } }, { configMapRef: { name: 'app-config' } }],
env: [
{ name: 'SHARED', value: 'explicit' },
{
name: 'FROM_SECRET',
valueFrom: { secretKeyRef: { name: 'app-secret', key: 'EXPLICIT_SOURCE' } },
},
],
},
(kind, name) => {
const resource = resources.get(`${kind}/${name}`)
return resource ? { state: 'found', resource } : { state: 'missing' }
}
)
expect(resolution.warning).toBeUndefined()
expect(resolution.values).toEqual(
new Map([
['SHARED', 'explicit'],
['SECRET_ONLY', 'secret-only'],
['EXPLICIT_SOURCE', 'from-secret-key'],
['CONFIG_ONLY', 'config-only'],
['FROM_SECRET', 'from-secret-key'],
])
)
})
it('returns unknown instead of claiming missing configuration when a Secret is inaccessible', () => {
const resolution = resolveKubernetesContainerEnvironment(
{ envFrom: [{ secretRef: { name: 'restricted-secret' } }] },
() => ({ state: 'inaccessible' })
)
expect(resolution.values).toBeNull()
expect(resolution.warning).toContain('RBAC')
expect(resolution.warning).not.toContain('secret-value')
})
})
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'bun:test'
import {
isPlaceholder,
isUsableSecret,
parseEnv,
reconcileEnvContent,
upsertEnv,
} from './env-files.ts'
describe('placeholder detection', () => {
it('recognizes underscore and hyphen template prefixes', () => {
expect(isPlaceholder('your_secret_key')).toBe(true)
expect(isPlaceholder('your-secure-production-auth-secret-here')).toBe(true)
expect(isUsableSecret('BETTER_AUTH_SECRET', 'your-secure-production-auth-secret-here')).toBe(
false
)
expect(isPlaceholder('yourActualSecret')).toBe(false)
})
})
describe('upsertEnv', () => {
it('writes the value that parseEnv will use', () => {
const updated = upsertEnv('RESEND_API_KEY=old\nOTHER=value\n', 'RESEND_API_KEY', 'current')
expect(parseEnv(updated).get('RESEND_API_KEY')).toBe('current')
})
it('fails fast when duplicate active entries make the effective write ambiguous', () => {
expect(() =>
upsertEnv(
'RESEND_API_KEY=old\nexport RESEND_API_KEY=current\n',
'RESEND_API_KEY',
'replacement'
)
).toThrow('Duplicate active RESEND_API_KEY entries')
})
})
describe('reconcileEnvContent', () => {
it('applies provider removals and replacements to one snapshot', () => {
const reconciled = reconcileEnvContent(
'SMTP_HOST=old-host\nSMTP_PORT=587\nRESEND_API_KEY=old-key\n',
['SMTP_HOST', 'SMTP_PORT'],
{ RESEND_API_KEY: 'new-key' }
)
expect(parseEnv(reconciled)).toEqual(new Map([['RESEND_API_KEY', 'new-key']]))
})
it('fails before returning content when a replacement key is duplicated', () => {
const content = 'SMTP_HOST=old-host\nRESEND_API_KEY=old-key\nRESEND_API_KEY=newer-key\n'
expect(() =>
reconcileEnvContent(content, ['SMTP_HOST'], { RESEND_API_KEY: 'replacement' })
).toThrow('Duplicate active RESEND_API_KEY entries')
expect(parseEnv(content).get('SMTP_HOST')).toBe('old-host')
})
})
+40 -9
View File
@@ -75,7 +75,7 @@ export function parseEnv(content: string): Map<string, string> {
const vars = new Map<string, string>()
for (const line of content.split('\n')) {
const match = LINE_RE.exec(line)
if (match && !vars.has(match[1])) vars.set(match[1], parseValue(match[2]))
if (match) vars.set(match[1], parseValue(match[2]))
}
return vars
}
@@ -95,7 +95,11 @@ export function upsertEnv(content: string, key: string, value: string): string {
const lines = content.split('\n')
const activeRe = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`)
const commentedRe = new RegExp(`^#\\s*${key}\\s*=`)
const activeIdx = lines.findIndex((l) => activeRe.test(l))
const activeIndexes = lines.flatMap((line, index) => (activeRe.test(line) ? [index] : []))
if (activeIndexes.length > 1) {
throw new Error(`Duplicate active ${key} entries found in environment file`)
}
const activeIdx = activeIndexes[0] ?? -1
const idx = activeIdx !== -1 ? activeIdx : lines.findIndex((l) => commentedRe.test(l))
const newLine = `${key}=${value}`
if (idx === -1) {
@@ -108,8 +112,33 @@ export function upsertEnv(content: string, key: string, value: string): string {
return lines.join('\n')
}
/** Writes values into an env file, seeding a missing file from its .env.example. */
export function writeEnvValues(target: EnvTarget, values: Record<string, string>): void {
/** Applies removals and replacements to one in-memory snapshot before it is written. */
export function reconcileEnvContent(
content: string,
remove: readonly string[],
values: Record<string, string>
): string {
const replacementKeys = new Set(Object.keys(values))
const removalKeys = new Set(remove.filter((key) => !replacementKeys.has(key)))
let reconciled = content
.split('\n')
.filter((line) => {
const match = LINE_RE.exec(line)
return !match || !removalKeys.has(match[1])
})
.join('\n')
for (const [key, value] of Object.entries(values)) {
reconciled = upsertEnv(reconciled, key, value)
}
return reconciled
}
/** Computes removals and replacements before writing the env file once. */
export function reconcileEnvValues(
target: EnvTarget,
remove: readonly string[],
values: Record<string, string>
): void {
const filePath = ENV_PATHS[target]
let content: string
if (existsSync(filePath)) {
@@ -118,10 +147,12 @@ export function writeEnvValues(target: EnvTarget, values: Record<string, string>
const example = EXAMPLE_PATHS[target]
content = example && existsSync(example) ? readFileSync(example, 'utf8') : ''
}
for (const [key, value] of Object.entries(values)) {
content = upsertEnv(content, key, value)
}
writeFileSync(filePath, content)
writeFileSync(filePath, reconcileEnvContent(content, remove, values))
}
/** Writes values into an env file, seeding a missing file from its .env.example. */
export function writeEnvValues(target: EnvTarget, values: Record<string, string>): void {
reconcileEnvValues(target, [], values)
}
export function archiveEnvFile(target: EnvTarget): string | null {
@@ -162,7 +193,7 @@ export function secretRequirement(key: string): string {
}
export function isPlaceholder(value: string): boolean {
return PLACEHOLDER_VALUES.has(value) || value.startsWith('your_')
return PLACEHOLDER_VALUES.has(value) || value.startsWith('your_') || value.startsWith('your-')
}
/**
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'bun:test'
import {
SANDBOX_CAPABILITY,
validateCapabilityFieldInput,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { SANDBOX_SETUP } from './capability-config.ts'
import { buildCapabilitySetupTransition } from './capability-setup.ts'
import type { ConfigurationSource } from './configuration-sources.ts'
import { reconcileLlmSetup, resolveFeatureSetupDestination } from './feature-setup.ts'
function source(
kind: ConfigurationSource['kind'],
managedByCurrentCheckout: boolean,
values: Map<string, string> | null = new Map()
): ConfigurationSource {
return {
kind,
label: `${kind} source`,
location: `${kind} location`,
values,
managedByCurrentCheckout,
}
}
describe('resolveFeatureSetupDestination', () => {
it('uses the effective values of the one setup-managed source', () => {
const effective = new Map([['RESEND_API_KEY', 'effective-key']])
const destination = resolveFeatureSetupDestination([
source('compose', false),
source('dev', true, effective),
])
expect(destination.target).toBe('sim')
expect(destination.containerized).toBe(false)
expect(destination.vars).toBe(effective)
})
it('maps a managed Compose source to the root env file', () => {
const destination = resolveFeatureSetupDestination([source('compose', true)])
expect(destination.target).toBe('root')
expect(destination.containerized).toBe(true)
})
it('refuses effective sources this checkout cannot safely update', () => {
expect(() => resolveFeatureSetupDestination([source('dev', false)])).toThrow(
/No effective configuration is safely writable/
)
expect(() => resolveFeatureSetupDestination([source('helm', false)])).toThrow(
/No effective configuration is safely writable/
)
})
it('refuses an unreadable managed source instead of claiming success', () => {
expect(() => resolveFeatureSetupDestination([source('compose', true, null)])).toThrow(
/effective environment could not be resolved/
)
})
})
describe('sandbox capability setup', () => {
it('requires an explicit non-floating snapshot tag', () => {
const validate = (value: string) =>
validateCapabilityFieldInput(SANDBOX_CAPABILITY, 'DAYTONA_SHELL_SNAPSHOT_ID', value)
expect(validate('mothership-shell:v1')).toBeUndefined()
expect(validate('mothership-shell')).toContain('name:tag')
expect(validate('mothership-shell:latest')).toContain('name:tag')
})
it('writes Daytona API and shell snapshot configuration and disables E2B', () => {
const result = buildCapabilitySetupTransition(
SANDBOX_SETUP,
'daytona',
{
DAYTONA_API_KEY: 'daytona-key',
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:v1',
},
{}
)
expect(result.remove).toContain('E2B_API_KEY')
expect(result.values).toMatchObject({
DAYTONA_API_KEY: 'daytona-key',
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:v1',
E2B_ENABLED: 'false',
NEXT_PUBLIC_E2B_ENABLED: 'false',
NEXT_PUBLIC_SANDBOX_ENABLED: 'true',
})
})
it('removes stale Daytona configuration for E2B and disabled modes', () => {
expect(
buildCapabilitySetupTransition(SANDBOX_SETUP, 'e2b', { E2B_API_KEY: 'e2b-key' }, {}).remove
).toEqual(expect.arrayContaining(['DAYTONA_API_KEY', 'DAYTONA_SHELL_SNAPSHOT_ID']))
expect(buildCapabilitySetupTransition(SANDBOX_SETUP, 'disabled', {}, {}).remove).toEqual(
expect.arrayContaining(['DAYTONA_API_KEY', 'DAYTONA_SHELL_SNAPSHOT_ID'])
)
})
})
describe('reconcileLlmSetup', () => {
it('removes trailing rotation keys omitted after empty-to-finish', () => {
expect(reconcileLlmSetup('openai', { OPENAI_API_KEY_1: 'replacement' })).toEqual({
values: { OPENAI_API_KEY_1: 'replacement' },
remove: ['OPENAI_API_KEY_2', 'OPENAI_API_KEY_3', 'OPENAI_API_KEY'],
})
})
it('removes a legacy fallback when rotation keys replace it', () => {
expect(reconcileLlmSetup('fireworks', { FIREWORKS_API_KEY_1: 'replacement' })).toEqual({
values: { FIREWORKS_API_KEY_1: 'replacement' },
remove: ['FIREWORKS_API_KEY_2', 'FIREWORKS_API_KEY_3', 'FIREWORKS_API_KEY'],
})
})
})
+205
View File
@@ -0,0 +1,205 @@
import {
LLM_KEY_POOLS,
OAUTH_CLIENT_CAPABILITIES,
resolveOAuthClientCapabilityId,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import {
getCapabilitySetup,
getOAuthClientSetupFields,
SETUP_FEATURES,
type SetupFeatureId,
} from './capability-config.ts'
import { promptCapabilitySetup } from './capability-setup.ts'
import { type ConfigurationSource, discoverConfigurationSources } from './configuration-sources.ts'
import { type EnvTarget, reconcileEnvValues } from './env-files.ts'
import * as p from './prompter.ts'
import { theme } from './theme.ts'
function isSetupFeatureId(value: string): value is SetupFeatureId {
return SETUP_FEATURES.some((feature) => feature.id === value)
}
async function setupIntegration(
requestedId: string | undefined,
vars: Map<string, string>
): Promise<Record<string, string>> {
if (!requestedId) {
throw new Error('Missing integration id. Example: bun run setup integration slack')
}
const providerId = resolveOAuthClientCapabilityId(requestedId)
if (!providerId) {
throw new Error(
`Unknown OAuth integration "${requestedId}". Expected one of: ${Object.keys(OAUTH_CLIENT_CAPABILITIES).join(', ')}`
)
}
const fields = getOAuthClientSetupFields(providerId)
const values: Record<string, string> = {}
for (const field of fields) {
const existing = vars.get(field.key)
if (field.input === 'secret') {
const value = await p.password({
message: existing ? `${field.key} (Currently used); leave empty to keep it` : field.key,
validate: (candidate) => (candidate || existing ? undefined : 'required'),
})
const resolved = value || existing
if (!resolved) throw new Error(`${field.key} was not provided`)
values[field.key] = resolved
} else {
values[field.key] = await p.text({
message: `${field.key}${existing ? ' (Currently used)' : ''}`,
initialValue: existing,
validate: (candidate) => (candidate ? undefined : 'required'),
})
}
}
p.log.info(`Configured the ${providerId} OAuth client.`)
return values
}
type LlmKeyPoolId = keyof typeof LLM_KEY_POOLS
export interface LlmSetupResult {
remove: readonly string[]
values: Record<string, string>
}
/** Reconciles every rotation and legacy fallback key owned by the selected pool. */
export function reconcileLlmSetup(
providerId: LlmKeyPoolId,
values: Record<string, string>
): LlmSetupResult {
const pool = LLM_KEY_POOLS[providerId]
const fields = [...pool.keys, ...('fallbackKey' in pool ? [pool.fallbackKey] : [])]
return {
values,
remove: fields.filter((key) => !Object.hasOwn(values, key)),
}
}
async function setupLlm(vars: Map<string, string>): Promise<LlmSetupResult> {
const currentProvider = Object.entries(LLM_KEY_POOLS).find(([, pool]) =>
[...pool.keys, ...('fallbackKey' in pool ? [pool.fallbackKey] : [])].some((key) =>
vars.has(key)
)
)?.[0] as LlmKeyPoolId | undefined
const provider = await p.select<LlmKeyPoolId>({
message: 'LLM key pool?',
options: Object.keys(LLM_KEY_POOLS).map((id) => ({
value: id as LlmKeyPoolId,
label: id,
hint: id === currentProvider ? 'Currently used' : undefined,
})),
initialValue: currentProvider,
})
const pool = LLM_KEY_POOLS[provider]
const keys = pool.keys
const values: Record<string, string> = {}
for (const [index, key] of keys.entries()) {
const legacyKey = index === 0 && 'fallbackKey' in pool ? pool.fallbackKey : undefined
const existingKey = vars.has(key) ? key : legacyKey
const existing = existingKey ? vars.get(existingKey) : undefined
const value = await p.password({
message: existing
? `${key} (${existingKey} is currently used); leave empty to keep it`
: `${key}${index === 0 ? '' : ' (empty to finish)'}`,
validate:
index === 0 ? (candidate) => (candidate || existing ? undefined : 'required') : undefined,
})
const resolved = value || existing
if (!resolved) break
values[key] = resolved
}
return reconcileLlmSetup(provider, values)
}
export function setupFeatureUsage(): string {
return SETUP_FEATURES.map((feature) =>
feature.id === 'integration' ? 'integration <slug>' : feature.id
).join(' | ')
}
export interface FeatureSetupDestination {
source: ConfigurationSource
target: Extract<EnvTarget, 'sim' | 'root'>
vars: Map<string, string>
containerized: boolean
}
/** Resolves the one effective configuration this checkout can safely update. */
export function resolveFeatureSetupDestination(
sources: readonly ConfigurationSource[]
): FeatureSetupDestination {
if (sources.length === 0) {
throw new Error('No Sim configuration was detected. Run bun run setup first.')
}
const managed = sources.filter((source) => source.managedByCurrentCheckout)
if (managed.length === 0) {
throw new Error(
'No effective configuration is safely writable by this checkout. Process overrides, higher-precedence development env files, external Compose projects, and Helm releases must be updated at their source. Run bun run setup status for the detected sources.'
)
}
if (managed.length > 1) {
throw new Error(
`More than one effective configuration is writable by this checkout (${managed.map((source) => source.label).join(', ')}). Run bun run setup status and remove the ambiguity before configuring a feature.`
)
}
const source = managed[0]
if (!source.values) {
throw new Error(
`${source.label} is managed by this checkout, but its effective environment could not be resolved. Run bun run setup status and fix the reported source error first.`
)
}
if (source.kind === 'helm') {
throw new Error(
'Helm configuration cannot be updated by bun run setup. Update the release Secret or values and upgrade the release.'
)
}
return {
source,
target: source.kind === 'compose' ? 'root' : 'sim',
vars: source.values,
containerized: source.kind === 'compose',
}
}
export async function runFeatureSetup(feature: string, args: readonly string[]): Promise<void> {
if (!isSetupFeatureId(feature)) {
throw new Error(`Unknown setup feature "${feature}". Expected: ${setupFeatureUsage()}`)
}
const destination = resolveFeatureSetupDestination(discoverConfigurationSources())
const { target, vars } = destination
let values: Record<string, string>
let remove: readonly string[]
const capabilitySetup = getCapabilitySetup(feature)
if (capabilitySetup) {
const result = await promptCapabilitySetup(capabilitySetup, vars, {
containerized: destination.containerized,
})
values = result.values
remove = result.remove
} else if (feature === 'integration') {
values = await setupIntegration(args[0], vars)
remove = []
} else if (feature === 'llm') {
const result = await setupLlm(vars)
values = result.values
remove = result.remove
} else {
throw new Error(`Setup feature ${feature} has no handler`)
}
reconcileEnvValues(target, remove, values)
const label = SETUP_FEATURES.find((item) => item.id === feature)?.label
p.outro(
theme.accent(
destination.containerized
? `${label} written to .env. Recreate the app container for it to take effect.`
: `${label} configured.`
)
)
}
+16
View File
@@ -2,14 +2,20 @@
import { getErrorMessage } from '@sim/utils/errors'
import { runDoctor } from './doctor.ts'
import { SetupError } from './errors.ts'
import { runFeatureSetup, setupFeatureUsage } from './feature-setup.ts'
import { isLifecycleCommand, runLifecycle } from './lifecycle.ts'
import { runSetupStatus } from './setup-status.ts'
import { exitWith, restoreTerminal } from './terminal.ts'
import { theme } from './theme.ts'
import { runWizard, type WizardMode } from './wizard.ts'
const USAGE = `Usage:
bun run setup run the setup wizard
bun run setup status show configured capabilities and integrations
bun run setup <feature> configure ${setupFeatureUsage()}
bun run sim setup [--quick] [--mode compose|dev|k8s]
bun run sim setup status show configured capabilities and integrations
bun run sim setup <feature> configure one feature
bun run sim doctor [--fix] [--json] check your setup
bun run sim start | stop | restart bring your install up / down / cycle
bun run sim status what's installed and healthy
@@ -56,6 +62,16 @@ async function main(): Promise<void> {
if (command === 'setup') {
const setupArgs = args.slice(1)
const feature = setupArgs[0]?.startsWith('-') ? undefined : setupArgs[0]
if (feature === 'status') {
process.exitCode = await runSetupStatus()
return
}
if (feature) {
const featureIndex = setupArgs.indexOf(feature)
await runFeatureSetup(feature, setupArgs.slice(featureIndex + 1))
return
}
const modeIdx = setupArgs.indexOf('--mode')
await runWizard({
quick: setupArgs.includes('--quick'),
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'bun:test'
import {
isMissingDependencyError,
missingDependenciesMessage,
retrySetupCommand,
} from './launcher.ts'
describe('setup launcher', () => {
it('recognizes missing packages without masking application import errors', () => {
expect(
isMissingDependencyError({
code: 'ERR_MODULE_NOT_FOUND',
message: "Cannot find package '@clack/prompts' from '/repo/scripts/setup/prompter.ts'",
})
).toBe(true)
expect(
isMissingDependencyError({
code: 'ERR_MODULE_NOT_FOUND',
message: "Cannot find module './missing-application-file.ts'",
})
).toBe(false)
expect(isMissingDependencyError(new Error('Invalid setup configuration'))).toBe(false)
})
it('prints the install command and the public retry command', () => {
expect(retrySetupCommand(['setup', 'status'])).toBe('bun run setup status')
expect(retrySetupCommand(['doctor'])).toBe('bun run sim doctor')
expect(missingDependenciesMessage('bun run setup status')).toContain('Run: bun install')
expect(missingDependenciesMessage('bun run setup status')).toContain(
'Then retry: bun run setup status'
)
})
})
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bun
interface ModuleResolutionError {
code?: unknown
message?: unknown
}
function asModuleResolutionError(error: unknown): ModuleResolutionError | null {
return typeof error === 'object' && error !== null ? error : null
}
/** Identifies dependency-resolution failures without masking setup/configuration errors. */
export function isMissingDependencyError(error: unknown): boolean {
const candidate = asModuleResolutionError(error)
if (candidate?.code !== 'ERR_MODULE_NOT_FOUND' && candidate?.code !== 'MODULE_NOT_FOUND') {
return false
}
return (
typeof candidate.message === 'string' &&
/Cannot find (?:package|module) ['"][^./][^'"]*['"]/.test(candidate.message)
)
}
/** Reconstructs the public command instead of exposing the internal launcher path. */
export function retrySetupCommand(args: readonly string[]): string {
if (args[0] === 'setup') return ['bun run setup', ...args.slice(1)].join(' ')
return ['bun run sim', ...args].join(' ')
}
export function missingDependenciesMessage(retryCommand: string): string {
return [
'',
'✗ Setup dependencies are missing or out of date.',
'',
' Run: bun install',
` Then retry: ${retryCommand}`,
].join('\n')
}
export async function launchSetupCli(
args: readonly string[] = process.argv.slice(2)
): Promise<void> {
try {
await import('./index.ts')
} catch (error) {
if (!isMissingDependencyError(error)) throw error
console.error(missingDependenciesMessage(retrySetupCommand(args)))
process.exitCode = 1
}
}
if (import.meta.main) await launchSetupCli()
+17 -8
View File
@@ -1,7 +1,9 @@
import { spawnSync } from 'node:child_process'
import { EMAIL_SETUP, STORAGE_SETUP } from '../capability-config.ts'
import { promptCapabilitySetup, stageCapabilitySetupTransition } from '../capability-setup.ts'
import type { Detection } from '../detect.ts'
import { ensureDocker } from '../docker.ts'
import { ROOT, readEnvFile, writeEnvValues } from '../env-files.ts'
import { ROOT, readEnvFile, reconcileEnvValues } from '../env-files.ts'
import { SetupError } from '../errors.ts'
import { ensurePortsFree } from '../ports.ts'
import { httpHealth, waitFor } from '../probes.ts'
@@ -11,11 +13,9 @@ import {
collectSecrets,
mothershipOverride,
promptCopilotKey,
promptEmail,
promptLlmKeys,
promptSecurity,
promptSignInProviders,
promptStorage,
promptUnlocks,
} from '../steps.ts'
import { glyph, theme } from '../theme.ts'
@@ -108,6 +108,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom
const root = readEnvFile('root')
const values = collectSecrets(root)
const remove = new Set<string>()
// Before the key is minted: a half-set override mints against one environment
// and validates against the other, and warning afterwards is too late — the
// bad key is already stored, and the next run offers to keep it.
@@ -117,11 +118,18 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom
Object.assign(values, chatFlagValues(copilotKey))
Object.assign(values, await promptLlmKeys(detection, !quick))
if (!quick) {
const storage = await promptStorage(root.vars, true)
if (storage) Object.assign(values, storage)
const stagedVars = new Map(root.vars)
for (const [key, value] of Object.entries(values)) stagedVars.set(key, value)
const storage = await promptCapabilitySetup(STORAGE_SETUP, stagedVars, {
containerized: true,
})
stageCapabilitySetupTransition(stagedVars, values, remove, storage)
const appUrl = root.vars.get('NEXT_PUBLIC_APP_URL') ?? APP_URL
Object.assign(values, await promptSignInProviders(root.vars, appUrl))
Object.assign(values, await promptEmail(root.vars))
Object.assign(values, await promptSignInProviders(stagedVars, appUrl))
const email = await promptCapabilitySetup(EMAIL_SETUP, stagedVars, {
containerized: true,
})
stageCapabilitySetupTransition(stagedVars, values, remove, email)
const security = await promptSecurity(root.vars)
Object.assign(values, security.sim, security.mirrorToRealtime)
Object.assign(values, await promptUnlocks(root.vars))
@@ -133,7 +141,8 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom
)
}
if (!root.vars.get('NEXT_TELEMETRY_DISABLED')) values.NEXT_TELEMETRY_DISABLED = '1'
writeEnvValues('root', values)
for (const key of Object.keys(values)) remove.delete(key)
reconcileEnvValues('root', [...remove], values)
p.log.step('Wrote .env (compose reads it for variable substitution)')
await ensureComposePortsFree(composeFile)
+17 -31
View File
@@ -1,9 +1,11 @@
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import { truncate } from '@sim/utils/string'
import { EMAIL_SETUP, JOBS_SETUP, STORAGE_SETUP } from '../capability-config.ts'
import { promptCapabilitySetup, stageCapabilitySetupTransition } from '../capability-setup.ts'
import { resolveDatabase } from '../db.ts'
import type { Detection } from '../detect.ts'
import { ROOT, readEnvFile, writeEnvValues } from '../env-files.ts'
import { ROOT, readEnvFile, reconcileEnvValues, writeEnvValues } from '../env-files.ts'
import { SetupError } from '../errors.ts'
import { pgProbe } from '../probes.ts'
import * as p from '../prompter.ts'
@@ -13,11 +15,9 @@ import {
collectSecrets,
mothershipOverride,
promptCopilotKey,
promptEmail,
promptLlmKeys,
promptSecurity,
promptSignInProviders,
promptStorage,
promptUnlocks,
} from '../steps.ts'
import { glyph, theme } from '../theme.ts'
@@ -70,27 +70,6 @@ async function promptRedis(detection: Detection, existing?: string): Promise<str
return resolveRedis(detection, existing)
}
async function promptTrigger(): Promise<Record<string, string> | null> {
const wants = await p.confirm({
message: 'Enable Trigger.dev for background jobs? (off = jobs run via the DB queue)',
initialValue: false,
})
if (!wants) return null
const secretKey = await p.password({
message: 'TRIGGER_SECRET_KEY',
validate: (v) => (v ? undefined : 'required'),
})
const projectId = await p.text({
message: 'TRIGGER_PROJECT_ID',
validate: (v) => (v ? undefined : 'required'),
})
return {
TRIGGER_DEV_ENABLED: 'true',
TRIGGER_SECRET_KEY: secretKey,
TRIGGER_PROJECT_ID: projectId,
}
}
export async function runDevMode(
detection: Detection,
quick: boolean
@@ -118,6 +97,7 @@ export async function runDevMode(
const simAfter = readEnvFile('sim')
const values: Record<string, string> = {}
const remove = new Set<string>()
// Before the key is minted: a half-set override mints against one environment
// and validates against the other, and warning afterwards is too late — the
// bad key is already stored, and the next run offers to keep it.
@@ -141,12 +121,15 @@ export async function runDevMode(
}
if (!quick) {
const trigger = await promptTrigger()
if (trigger) Object.assign(values, trigger)
const storage = await promptStorage(simAfter.vars, false)
if (storage) Object.assign(values, storage)
Object.assign(values, await promptSignInProviders(simAfter.vars, APP_URL))
Object.assign(values, await promptEmail(simAfter.vars))
const stagedVars = new Map(simAfter.vars)
for (const [key, value] of Object.entries(values)) stagedVars.set(key, value)
for (const setup of [JOBS_SETUP, STORAGE_SETUP, EMAIL_SETUP] as const) {
const transition = await promptCapabilitySetup(setup, stagedVars, {
containerized: false,
})
stageCapabilitySetupTransition(stagedVars, values, remove, transition)
}
Object.assign(values, await promptSignInProviders(stagedVars, APP_URL))
const security = await promptSecurity(simAfter.vars)
Object.assign(values, security.sim)
if (Object.keys(security.mirrorToRealtime).length > 0) {
@@ -154,7 +137,10 @@ export async function runDevMode(
}
Object.assign(values, await promptUnlocks(simAfter.vars))
}
if (Object.keys(values).length > 0) writeEnvValues('sim', values)
for (const key of Object.keys(values)) remove.delete(key)
if (remove.size > 0 || Object.keys(values).length > 0) {
reconcileEnvValues('sim', [...remove], values)
}
let script = 'dev:full'
if (detection.specs.hostMemGb < 16) {
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'bun:test'
import type { ConfigurationSource } from './configuration-sources'
import { buildSetupStatusReport, renderSetupStatusReport } from './setup-status'
function source(values: Record<string, string>): ConfigurationSource {
return {
kind: 'dev',
label: 'Local dev',
location: 'apps/sim/.env',
values: new Map(Object.entries(values)),
managedByCurrentCheckout: true,
}
}
const CORE_VALUES = {
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/sim',
BETTER_AUTH_SECRET: 'a'.repeat(32),
BETTER_AUTH_URL: 'http://localhost:3000',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
ENCRYPTION_KEY: 'b'.repeat(64),
INTERNAL_API_SECRET: 'c'.repeat(32),
}
describe('setup status', () => {
it('renders providers and missing integrations without exposing configured values', () => {
const report = buildSetupStatusReport(
source({
...CORE_VALUES,
RESEND_API_KEY: 'resend-super-secret',
SLACK_CLIENT_ID: 'slack-client-secret-value',
SLACK_CLIENT_SECRET: 'slack-client-secret',
})
)
const output = renderSetupStatusReport(report)
expect(report.failed).toBe(false)
expect(output).toContain('Email delivery: Resend')
expect(output).toContain('OAuth ready')
expect(output).toContain('Slack')
expect(output).toContain('Unavailable')
expect(output).not.toContain('resend-super-secret')
expect(output).not.toContain('slack-client-secret-value')
expect(output).not.toContain('slack-client-secret')
expect(report.source).not.toHaveProperty('values')
})
it('fails on partial configuration while continuing to render other capabilities', () => {
const report = buildSetupStatusReport(
source({
...CORE_VALUES,
SMTP_HOST: 'localhost',
MICROSOFT_CLIENT_ID: 'partial-client',
})
)
const output = renderSetupStatusReport(report)
expect(report.failed).toBe(true)
expect(output).toContain('Email delivery: Not configured')
expect(output).toContain('SMTP_PORT')
expect(output).toContain('Misconfigured')
expect(output).toContain('MICROSOFT_CLIENT_SECRET')
expect(output).not.toContain('partial-client')
})
it('warns about an incomplete email fallback without failing a configured provider', () => {
const report = buildSetupStatusReport(
source({
...CORE_VALUES,
RESEND_API_KEY: 'resend-super-secret',
SMTP_HOST: 'localhost',
})
)
const output = renderSetupStatusReport(report)
expect(report.failed).toBe(false)
expect(report.capabilityStatus?.features.email.state).toBe('configured')
expect(output).toContain('Email delivery: Resend')
expect(output).toContain('SMTP_PORT')
expect(output).toContain('configure: bun run setup email')
expect(output).not.toContain('resend-super-secret')
})
it('marks an unreadable effective source unknown instead of missing', () => {
const unknown: ConfigurationSource = {
kind: 'helm',
label: 'Helm release sim',
location: 'context prod · namespace sim',
values: null,
warning: 'cannot read app Secret',
managedByCurrentCheckout: false,
}
const report = buildSetupStatusReport(unknown)
const output = renderSetupStatusReport(report)
expect(report.failed).toBe(true)
expect(output).toContain('Effective environment is unavailable')
expect(output).not.toContain('Not configured')
})
it('fails on a partial OAuth client even when it is not in the visible catalog', () => {
const report = buildSetupStatusReport(
source({
...CORE_VALUES,
SPOTIFY_CLIENT_ID: 'partial-client-value',
})
)
const output = renderSetupStatusReport(report)
expect(report.failed).toBe(true)
expect(output).toContain('Spotify OAuth client: partial')
expect(output).toContain('SPOTIFY_CLIENT_SECRET')
expect(output).not.toContain('partial-client-value')
})
it('names fields missing from an explicitly selected storage provider', () => {
const report = buildSetupStatusReport(
source({
...CORE_VALUES,
STORAGE_PROVIDER: 's3',
AWS_REGION: 'us-east-1',
})
)
const output = renderSetupStatusReport(report)
expect(report.failed).toBe(true)
expect(output).toContain('S3_BUCKET_NAME')
})
it('fails on missing split-development files without retaining source values', () => {
const development = source(CORE_VALUES)
development.configurationIssues = ['apps/realtime/.env is missing']
const report = buildSetupStatusReport(development)
const output = renderSetupStatusReport(report)
expect(report.failed).toBe(true)
expect(output).toContain('apps/realtime/.env is missing')
expect(report.source).not.toHaveProperty('values')
})
})
+358
View File
@@ -0,0 +1,358 @@
import {
type EnvCapabilityValues,
hasEnvCapabilityValue,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import {
type IntegrationAvailability,
resolveIntegrationAvailability,
} from '../../apps/sim/lib/integrations/availability.ts'
import { SETUP_FEATURES } from './capability-config.ts'
import {
buildEnvCapabilityStatus,
type EnvCapabilityFeatureStatuses,
type SetupStatusFeatureId,
} from './capability-status.ts'
import { REQUIRED_APP_KEYS } from './checks.ts'
import { type ConfigurationSource, discoverConfigurationSources } from './configuration-sources.ts'
import { isPlaceholder, isUsableSecret } from './env-files.ts'
import { glyph, theme } from './theme.ts'
type FeatureStatus = EnvCapabilityFeatureStatuses[keyof EnvCapabilityFeatureStatuses]
interface CoreConfigurationIssue {
key: (typeof REQUIRED_APP_KEYS)[number]
reason: 'missing' | 'placeholder' | 'invalid secret' | 'invalid URL'
}
export type SetupStatusSource = Omit<ConfigurationSource, 'values'>
export interface SetupStatusReport {
source: SetupStatusSource
environmentAvailable: boolean
coreIssues: readonly CoreConfigurationIssue[]
capabilityStatus: ReturnType<typeof buildEnvCapabilityStatus> | null
integrationAvailability: readonly IntegrationAvailability[] | null
failed: boolean
}
const SECRET_KEYS = new Set(['BETTER_AUTH_SECRET', 'ENCRYPTION_KEY', 'INTERNAL_API_SECRET'])
const URL_KEYS = new Set(['DATABASE_URL', 'BETTER_AUTH_URL', 'NEXT_PUBLIC_APP_URL'])
const FEATURE_ORDER: readonly SetupStatusFeatureId[] = SETUP_FEATURES.flatMap((feature) =>
feature.id === 'integration' ? [] : [feature.id]
)
function readString(values: EnvCapabilityValues, key: string): string | undefined {
const value =
values instanceof Map ? values.get(key) : (values as Readonly<Record<string, unknown>>)[key]
return value === undefined || value === null ? undefined : String(value)
}
function inspectCoreConfiguration(values: EnvCapabilityValues): CoreConfigurationIssue[] {
const issues: CoreConfigurationIssue[] = []
for (const key of REQUIRED_APP_KEYS) {
const value = readString(values, key)
if (!hasEnvCapabilityValue(values, key)) {
issues.push({ key, reason: 'missing' })
} else if (value && isPlaceholder(value)) {
issues.push({ key, reason: 'placeholder' })
} else if (value && SECRET_KEYS.has(key) && !isUsableSecret(key, value)) {
issues.push({ key, reason: 'invalid secret' })
} else if (value && URL_KEYS.has(key)) {
try {
new URL(value)
} catch {
issues.push({ key, reason: 'invalid URL' })
}
}
}
return issues
}
function titleCase(value: string): string {
if (value === 'openai') return 'OpenAI'
return value
.split(/[-_]/)
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(' ')
}
function featureDetail(feature: FeatureStatus): string {
switch (feature.id) {
case 'email':
return feature.providerIds.length > 0
? feature.providerIds
.map(
(id) =>
feature.providers.find((provider) => provider.id === id)?.label ?? titleCase(id)
)
.join(' → ')
: 'Not configured'
case 'storage':
if (feature.providerId === 'local') return 'Local disk (default)'
return (
feature.providers.find((provider) => provider.id === feature.providerId)?.label ??
(feature.providerId ? titleCase(feature.providerId) : 'Not configured')
)
case 'sandbox':
if (feature.providerId === 'disabled') return 'Disabled (local JavaScript only)'
return feature.providerId ? titleCase(feature.providerId) : 'Not configured'
case 'jobs':
return feature.providerId === 'database' ? 'Database queue (default)' : 'Trigger.dev'
case 'cache':
return feature.providerId === 'database' ? 'Postgres (default)' : 'Redis'
case 'knowledge':
if (feature.providerId === 'local') return 'Local parser (default)'
if (feature.providerId === 'azure-mistral') return 'Azure Mistral OCR'
return feature.providerId === 'mistral' ? 'Mistral OCR' : 'Not configured'
case 'llm': {
const configured = Object.values(feature.pools)
.filter((pool) => pool.state === 'configured')
.map((pool) => `${titleCase(pool.id)} (${pool.effectiveKeyCount})`)
return configured.length > 0 ? configured.join(', ') : 'No global key pools'
}
}
}
function featureGlyph(feature: FeatureStatus): string {
if (feature.issue && (feature.state === 'configured' || feature.state === 'default')) {
return glyph.warn
}
if (feature.issue || feature.state === 'partial' || feature.state === 'invalid') return glyph.fail
if (feature.state === 'missing') return glyph.skip
return glyph.pass
}
function withoutSetupCommand(message: string): string {
return message.replace(/\s+Run bun run setup[^.]*\.$/, '')
}
function setupHint(source: SetupStatusSource, command: string): string | null {
if (source.managedByCurrentCheckout) return command
if (source.kind === 'helm') return 'update the app Secret/values and upgrade this Helm release'
if (source.kind === 'compose') return 'update this Compose project and recreate its app container'
return null
}
function uniqueNames(integrations: readonly IntegrationAvailability[]): string[] {
return [...new Set(integrations.map((integration) => integration.name))].sort((a, b) =>
a.localeCompare(b)
)
}
interface IntegrationGroup {
setupCommand?: string
missingFields: readonly string[]
names: string[]
}
function groupIntegrations(
integrations: readonly IntegrationAvailability[]
): readonly IntegrationGroup[] {
const groups = new Map<string, IntegrationGroup>()
for (const integration of integrations) {
const missingFields = [...integration.missingFields].sort()
const key = `${integration.setupCommand ?? 'clientless'}:${missingFields.join(',')}`
const existing = groups.get(key)
if (existing) {
if (!existing.names.includes(integration.name)) existing.names.push(integration.name)
continue
}
groups.set(key, {
setupCommand: integration.setupCommand,
missingFields,
names: [integration.name],
})
}
return [...groups.values()].map((group) => ({
...group,
names: group.names.sort((left, right) => left.localeCompare(right)),
}))
}
function renderIntegrationGroup(
source: SetupStatusSource,
marker: string,
group: IntegrationGroup
): string[] {
const missing =
group.missingFields.length > 0 ? ` — missing ${group.missingFields.join(', ')}` : ''
const lines = [` ${marker} ${group.names.join(', ')}${missing}`]
if (group.setupCommand) {
const hint = setupHint(source, group.setupCommand)
if (hint) lines.push(` ${theme.muted(`configure: ${hint}`)}`)
}
return lines
}
/** Builds a non-secret report for one effective deployment configuration. */
export function buildSetupStatusReport(source: ConfigurationSource): SetupStatusReport {
const safeSource: SetupStatusSource = {
kind: source.kind,
label: source.label,
location: source.location,
managedByCurrentCheckout: source.managedByCurrentCheckout,
...(source.warning ? { warning: source.warning } : {}),
...(source.configurationIssues ? { configurationIssues: source.configurationIssues } : {}),
}
if (!source.values) {
return {
source: safeSource,
environmentAvailable: false,
coreIssues: [],
capabilityStatus: null,
integrationAvailability: null,
failed: true,
}
}
const coreIssues = inspectCoreConfiguration(source.values)
const capabilityStatus = buildEnvCapabilityStatus(source.values)
const integrationAvailability = resolveIntegrationAvailability(source.values)
const brokenCapability = Object.values(capabilityStatus.features).some(
(feature) =>
feature.state === 'partial' ||
feature.state === 'invalid' ||
(feature.state === 'missing' && Boolean(feature.issue))
)
const brokenIntegration = integrationAvailability.some(
(integration) => integration.state === 'misconfigured'
)
const brokenOAuthClient =
capabilityStatus.oauthClients.partialCount > 0 || capabilityStatus.oauthClients.invalidCount > 0
return {
source: safeSource,
environmentAvailable: true,
coreIssues,
capabilityStatus,
integrationAvailability,
failed:
coreIssues.length > 0 ||
Boolean(source.configurationIssues?.length) ||
brokenCapability ||
brokenIntegration ||
brokenOAuthClient,
}
}
/** Renders a report without including any configured environment values. */
export function renderSetupStatusReport(report: SetupStatusReport): string {
const { source } = report
const lines = [
theme.heading(source.label),
` ${report.environmentAvailable ? glyph.pass : glyph.fail} ${source.location}`,
]
if (source.warning) lines.push(` ${glyph.warn} ${source.warning}`)
lines.push('')
if (!report.environmentAvailable || !report.capabilityStatus || !report.integrationAvailability) {
lines.push(theme.heading('Configuration'))
lines.push(` ${glyph.fail} Effective environment is unavailable.`)
return lines.join('\n')
}
lines.push(theme.heading('Core configuration'))
if (report.coreIssues.length === 0 && !source.configurationIssues?.length) {
lines.push(` ${glyph.pass} All ${REQUIRED_APP_KEYS.length} required app values are present`)
} else {
for (const coreIssue of report.coreIssues) {
lines.push(` ${glyph.fail} ${coreIssue.key}: ${coreIssue.reason}`)
}
for (const configurationIssue of source.configurationIssues ?? []) {
lines.push(` ${glyph.fail} ${configurationIssue}`)
}
}
lines.push('')
lines.push(theme.heading('Capabilities'))
for (const id of FEATURE_ORDER) {
const feature = report.capabilityStatus.features[id]
lines.push(` ${featureGlyph(feature)} ${feature.label}: ${featureDetail(feature)}`)
if (feature.issue) {
lines.push(` ${theme.muted(withoutSetupCommand(feature.issue.message))}`)
}
if (feature.state === 'missing' || feature.issue) {
const hint = setupHint(source, feature.setupCommand)
if (hint) lines.push(` ${theme.muted(`configure: ${hint}`)}`)
}
}
lines.push('')
const deploymentIntegrations = report.integrationAvailability.filter(
(integration) => integration.setupCommand || integration.serviceAccountAvailable
)
const ready = deploymentIntegrations.filter((integration) => integration.state === 'ready')
const limited = deploymentIntegrations.filter((integration) => integration.state === 'limited')
const unavailable = deploymentIntegrations.filter(
(integration) => integration.state === 'unavailable'
)
const misconfigured = deploymentIntegrations.filter(
(integration) => integration.state === 'misconfigured'
)
lines.push(theme.heading('OAuth integrations'))
const readyNames = uniqueNames(ready)
lines.push(
readyNames.length > 0
? ` ${glyph.pass} OAuth ready (${readyNames.length}): ${readyNames.join(', ')}`
: ` ${glyph.skip} OAuth ready: none`
)
const limitedNames = uniqueNames(limited)
if (limitedNames.length > 0) {
lines.push(
` ${glyph.warn} OAuth unavailable; workspace credential option exists (${limitedNames.length}): ${limitedNames.join(', ')}`
)
for (const group of groupIntegrations(limited)) {
lines.push(...renderIntegrationGroup(source, glyph.warn, group))
}
}
if (unavailable.length > 0) {
lines.push(` ${glyph.skip} Unavailable (${uniqueNames(unavailable).length})`)
for (const group of groupIntegrations(unavailable)) {
lines.push(...renderIntegrationGroup(source, glyph.skip, group))
}
}
if (misconfigured.length > 0) {
lines.push(` ${glyph.fail} Misconfigured (${uniqueNames(misconfigured).length})`)
for (const group of groupIntegrations(misconfigured)) {
lines.push(...renderIntegrationGroup(source, glyph.fail, group))
}
}
const representedOAuthClients = new Set(
deploymentIntegrations.flatMap((integration) => {
if (!integration.setupCommand) return []
return [integration.setupCommand.replace('bun run setup integration ', '')]
})
)
const additionalOAuthClients = Object.values(report.capabilityStatus.oauthClients.clients).filter(
(client) => !representedOAuthClients.has(client.id) && client.state !== 'absent'
)
for (const client of additionalOAuthClients) {
const marker = client.state === 'ready' ? glyph.pass : glyph.fail
const missing =
client.missingFields.length > 0 ? ` — missing ${client.missingFields.join(', ')}` : ''
lines.push(` ${marker} ${titleCase(client.id)} OAuth client: ${client.state}${missing}`)
if (client.state !== 'ready') {
const hint = setupHint(source, client.setupCommand)
if (hint) lines.push(` ${theme.muted(`configure: ${hint}`)}`)
}
}
lines.push(
` ${theme.muted('API-key integrations are configured per workspace and are not listed.')}`
)
return lines.join('\n')
}
export async function runSetupStatus(): Promise<number> {
const sources = await discoverConfigurationSources()
console.log(`\n${theme.heading('◆ Sim setup status')}\n`)
if (sources.length === 0) {
console.log(` ${glyph.fail} No local-dev, Docker Compose, or Helm configuration detected.`)
console.log(` ${theme.muted('run: bun run setup')}`)
return 1
}
const reports = sources.map(buildSetupStatusReport)
console.log(reports.map(renderSetupStatusReport).join('\n\n'))
return reports.some((report) => report.failed) ? 1 : 0
}
+232
View File
@@ -0,0 +1,232 @@
import { describe, expect, it } from 'bun:test'
import {
EMAIL_CAPABILITY,
inspectCapability,
requireCapability,
STORAGE_CAPABILITY,
validateCapabilityFieldInput,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { EMAIL_SETUP, STORAGE_SETUP } from './capability-config.ts'
import {
buildCapabilitySetupTransition,
resolveCurrentCapabilitySetupOptionId,
} from './capability-setup.ts'
function applyResult(
initial: Record<string, string>,
result: { values: Record<string, string>; remove: readonly string[] }
): Record<string, string> {
const reconciled = { ...initial }
for (const key of result.remove) Reflect.deleteProperty(reconciled, key)
return Object.assign(reconciled, result.values)
}
describe('setup provider reconciliation', () => {
it('defaults email setup to the first runtime-ready fallback', () => {
const vars = new Map([
['SMTP_HOST', 'smtp.example.com'],
[
'GMAIL_CREDENTIALS_JSON',
JSON.stringify({ client_email: 'service@example.com', private_key: 'secret' }),
],
['GMAIL_SENDER', 'sender@example.com'],
])
expect(resolveCurrentCapabilitySetupOptionId(EMAIL_SETUP, vars)).toBe('gmail')
expect(
resolveCurrentCapabilitySetupOptionId(
EMAIL_SETUP,
new Map([['SMTP_HOST', 'smtp.example.com']])
)
).toBe('smtp')
})
it('uses canonical storage selection for setup defaults', () => {
expect(
resolveCurrentCapabilitySetupOptionId(STORAGE_SETUP, new Map([['AWS_REGION', 'us-east-1']]))
).toBe('local')
expect(
resolveCurrentCapabilitySetupOptionId(
STORAGE_SETUP,
new Map([
['STORAGE_PROVIDER', ' S3 '],
['AWS_REGION', 'us-east-1'],
['S3_BUCKET_NAME', 'files'],
['S3_ENDPOINT', 'https://storage.example.com'],
])
)
).toBe('s3')
})
it('preserves other ready email providers when another fallback is configured', () => {
const result = buildCapabilitySetupTransition(
EMAIL_SETUP,
'resend',
{ RESEND_API_KEY: 'new-resend-key' },
{}
)
const reconciled = applyResult(
{
SMTP_HOST: 'smtp.example.com',
SMTP_PORT: '587',
SMTP_USER: 'old-user',
SMTP_PASS: 'old-pass',
},
result
)
expect(result.remove).not.toEqual(expect.arrayContaining(['SMTP_HOST', 'SMTP_PORT']))
expect(inspectCapability(EMAIL_CAPABILITY, reconciled).providerIds).toEqual(['resend', 'smtp'])
})
it('clears stale SMTP auth for an unauthenticated relay', () => {
const result = buildCapabilitySetupTransition(
EMAIL_SETUP,
'smtp',
{ SMTP_HOST: 'localhost', SMTP_PORT: '1025' },
{}
)
const reconciled = applyResult({ SMTP_USER: 'old-user', SMTP_PASS: 'old-pass' }, result)
expect(reconciled).not.toHaveProperty('SMTP_USER')
expect(reconciled).not.toHaveProperty('SMTP_PASS')
expect(inspectCapability(EMAIL_CAPABILITY, reconciled).providerIds).toEqual(['smtp'])
})
it('clears stale static S3 credentials when IAM is selected', () => {
const result = buildCapabilitySetupTransition(
STORAGE_SETUP,
's3',
{
AWS_REGION: 'us-east-1',
S3_BUCKET_NAME: 'files',
},
{}
)
const reconciled = applyResult(
{
AWS_ACCESS_KEY_ID: 'old-access-key',
AWS_SECRET_ACCESS_KEY: 'old-secret-key',
S3_ENDPOINT: 'https://old-endpoint.example.com',
},
result
)
expect(reconciled).not.toHaveProperty('AWS_ACCESS_KEY_ID')
expect(reconciled).not.toHaveProperty('AWS_SECRET_ACCESS_KEY')
expect(reconciled).not.toHaveProperty('S3_ENDPOINT')
expect(requireCapability(STORAGE_CAPABILITY, reconciled).providerId).toBe('s3')
})
it('preserves specialized S3 bucket overrides that setup does not prompt for', () => {
const result = buildCapabilitySetupTransition(
STORAGE_SETUP,
's3',
{
AWS_REGION: 'us-east-1',
S3_BUCKET_NAME: 'files',
},
{
S3_KB_BUCKET_NAME: 'knowledge',
S3_CHAT_BUCKET_NAME: 'chat',
}
)
const reconciled = applyResult(
{
S3_KB_BUCKET_NAME: 'knowledge',
S3_CHAT_BUCKET_NAME: 'chat',
},
result
)
expect(result.remove).not.toEqual(
expect.arrayContaining(['S3_KB_BUCKET_NAME', 'S3_CHAT_BUCKET_NAME'])
)
expect(reconciled.S3_KB_BUCKET_NAME).toBe('knowledge')
expect(reconciled.S3_CHAT_BUCKET_NAME).toBe('chat')
expect(requireCapability(STORAGE_CAPABILITY, reconciled).providerId).toBe('s3')
})
it('clears specialized S3 bucket overrides when switching to local storage', () => {
const result = buildCapabilitySetupTransition(
STORAGE_SETUP,
'local',
{},
{
AWS_REGION: 'us-east-1',
S3_BUCKET_NAME: 'files',
S3_KB_BUCKET_NAME: 'knowledge',
S3_CHAT_BUCKET_NAME: 'chat',
}
)
const reconciled = applyResult(
{
AWS_REGION: 'us-east-1',
S3_BUCKET_NAME: 'files',
S3_KB_BUCKET_NAME: 'knowledge',
S3_CHAT_BUCKET_NAME: 'chat',
},
result
)
expect(result.remove).toEqual(
expect.arrayContaining(['S3_KB_BUCKET_NAME', 'S3_CHAT_BUCKET_NAME'])
)
expect(reconciled).not.toHaveProperty('S3_KB_BUCKET_NAME')
expect(reconciled).not.toHaveProperty('S3_CHAT_BUCKET_NAME')
expect(requireCapability(STORAGE_CAPABILITY, reconciled).providerId).toBe('local')
})
it('clears stale inline GCS credentials when ADC is selected', () => {
const result = buildCapabilitySetupTransition(
STORAGE_SETUP,
'gcs',
{ GCS_BUCKET_NAME: 'files' },
{}
)
const reconciled = applyResult(
{
GCS_PROJECT_ID: 'old-project',
GCS_CREDENTIALS_JSON: JSON.stringify({
client_email: 'old@example.com',
private_key: 'old-key',
}),
},
result
)
expect(reconciled).not.toHaveProperty('GCS_PROJECT_ID')
expect(reconciled).not.toHaveProperty('GCS_CREDENTIALS_JSON')
expect(requireCapability(STORAGE_CAPABILITY, reconciled).providerId).toBe('gcs')
})
})
describe('setup input validation', () => {
it('validates SMTP ports with the runtime capability rule', () => {
const validate = (value: string) =>
validateCapabilityFieldInput(EMAIL_CAPABILITY, 'SMTP_PORT', value)
expect(validate('587')).toBeUndefined()
expect(validate('0')).toContain('between 1 and 65535')
expect(validate('587.5')).toContain('between 1 and 65535')
})
it('accepts only HTTP(S) S3 endpoints', () => {
const validate = (value: string) =>
validateCapabilityFieldInput(STORAGE_CAPABILITY, 'S3_ENDPOINT', value)
expect(validate('https://account.r2.cloudflarestorage.com')).toBeUndefined()
expect(validate('http://minio:9000')).toBeUndefined()
expect(validate('ftp://storage.example.com')).toContain('http:// or https://')
expect(validate('not-a-url')).toContain('http:// or https://')
})
it('requires complete inline service-account JSON', () => {
const validate = (value: string) =>
validateCapabilityFieldInput(EMAIL_CAPABILITY, 'GMAIL_CREDENTIALS_JSON', value)
expect(
validate(JSON.stringify({ client_email: 'service@example.com', private_key: 'secret' }))
).toBeUndefined()
expect(validate('{"client_email":"service@example.com"}')).toContain(
'client_email and private_key'
)
})
})
+12 -161
View File
@@ -11,7 +11,7 @@ import {
} from './env-files.ts'
import * as p from './prompter.ts'
import { link, theme } from './theme.ts'
import { FLAG_TWINS, hasMailProvider, LOGIN_PROVIDERS, SELF_HOST_UNLOCKS } from './twins.ts'
import { FLAG_TWINS, LOGIN_PROVIDERS, SELF_HOST_UNLOCKS } from './twins.ts'
/** Where the Chat key is minted when SIM_CLI_AUTH_ORIGIN is unset. */
const DEFAULT_CLI_AUTH_ORIGIN = 'https://www.sim.ai'
@@ -149,116 +149,6 @@ export async function promptLlmKeys(
return values
}
type StorageBackend = 'local' | 's3' | 's3compat' | 'azure' | 'gcs'
function detectStorageBackend(vars: Map<string, string>): StorageBackend {
if (vars.get('AZURE_CONNECTION_STRING') || vars.get('AZURE_ACCOUNT_NAME')) return 'azure'
if (vars.get('S3_ENDPOINT')) return 's3compat'
if (vars.get('S3_BUCKET_NAME') || vars.get('AWS_REGION')) return 's3'
if (vars.get('GCS_BUCKET_NAME')) return 'gcs'
return 'local'
}
async function required(message: string, initialValue?: string): Promise<string> {
return p.text({ message, initialValue, validate: (v) => (v ? undefined : 'required') })
}
/**
* Custom-flow storage step. Local disk is the default; a cloud backend is
* strongly recommended for containerized deployments (uploads are ephemeral
* there). Returns the env vars for the chosen backend, or null to keep local.
*/
export async function promptStorage(
vars: Map<string, string>,
containerized: boolean
): Promise<Record<string, string> | null> {
const current = detectStorageBackend(vars)
const backend = await p.select<StorageBackend>({
message: 'File storage?',
options: [
{
value: 'local',
label: 'Local disk',
hint: containerized
? 'files live in the container — LOST on restart; fine only for evaluation'
: 'fine for local dev (external-fetch flows like Instagram publish need cloud storage)',
},
{ value: 's3', label: 'AWS S3', hint: 'region + bucket; keys optional with IAM/IRSA' },
{
value: 's3compat',
label: 'S3-compatible (R2, MinIO, B2)',
hint: 'custom endpoint — fully self-hostable with MinIO',
},
{ value: 'azure', label: 'Azure Blob', hint: 'connection string or account name + key' },
{
value: 'gcs',
label: 'Google Cloud Storage',
hint: 'bucket; credentials via ADC by default',
},
],
initialValue: current,
})
if (backend === 'local') return null
const values: Record<string, string> = {}
if (backend === 's3' || backend === 's3compat') {
if (backend === 's3compat') {
values.S3_ENDPOINT = await required(
'S3_ENDPOINT (e.g. https://<account>.r2.cloudflarestorage.com)',
vars.get('S3_ENDPOINT')
)
const pathStyle = await p.confirm({
message: 'Force path-style addressing? (required for MinIO/Ceph, not for R2)',
initialValue: false,
})
if (pathStyle) values.S3_FORCE_PATH_STYLE = 'true'
}
values.AWS_REGION = await required(
'AWS_REGION',
vars.get('AWS_REGION') ?? (backend === 's3compat' ? 'auto' : undefined)
)
values.S3_BUCKET_NAME = await required('S3_BUCKET_NAME', vars.get('S3_BUCKET_NAME'))
const accessKey = await p.password({
message: 'AWS_ACCESS_KEY_ID (empty = IAM/instance credential chain)',
})
if (accessKey) {
values.AWS_ACCESS_KEY_ID = accessKey
values.AWS_SECRET_ACCESS_KEY = await p.password({
message: 'AWS_SECRET_ACCESS_KEY',
validate: (v) => (v ? undefined : 'required when an access key id is set'),
})
}
} else if (backend === 'azure') {
const connectionString = await p.password({
message: 'AZURE_CONNECTION_STRING (empty = use account name + key)',
})
if (connectionString) {
values.AZURE_CONNECTION_STRING = connectionString
} else {
values.AZURE_ACCOUNT_NAME = await required(
'AZURE_ACCOUNT_NAME',
vars.get('AZURE_ACCOUNT_NAME')
)
values.AZURE_ACCOUNT_KEY = await p.password({
message: 'AZURE_ACCOUNT_KEY',
validate: (v) => (v ? undefined : 'required'),
})
}
values.AZURE_STORAGE_CONTAINER_NAME = await required(
'AZURE_STORAGE_CONTAINER_NAME',
vars.get('AZURE_STORAGE_CONTAINER_NAME') ?? 'sim-files'
)
} else {
values.GCS_BUCKET_NAME = await required('GCS_BUCKET_NAME', vars.get('GCS_BUCKET_NAME'))
p.log.info(
theme.muted(
'Credentials use Application Default Credentials unless GCS_CREDENTIALS_JSON is set.'
)
)
}
return values
}
const PROVIDER_CONSOLES: Record<string, string> = {
google: 'https://console.cloud.google.com/apis/credentials',
github: 'https://github.com/settings/developers',
@@ -276,7 +166,7 @@ export async function promptSignInProviders(
options: LOGIN_PROVIDERS.map((prov) => ({
value: prov.id,
label: prov.label,
hint: configured.includes(prov.id) ? 'already configured' : undefined,
hint: configured.includes(prov.id) ? 'Currently used' : undefined,
})),
initialValues: configured,
})
@@ -288,59 +178,20 @@ export async function promptSignInProviders(
`${provider.label}: create an OAuth app at ${link(PROVIDER_CONSOLES[id], PROVIDER_CONSOLES[id])}\n Redirect URI: ${theme.command(`${appUrl}/api/auth/callback/${id}`)}`
)
values[provider.idKey] = await p.text({
message: provider.idKey,
message: `${provider.idKey}${vars.has(provider.idKey) ? ' (Currently used)' : ''}`,
initialValue: vars.get(provider.idKey),
validate: (v) => (v ? undefined : 'required'),
})
values[provider.secretKey] = await p.password({
message: provider.secretKey,
validate: (v) => (v ? undefined : 'required'),
const existingSecret = vars.get(provider.secretKey)
const secret = await p.password({
message: existingSecret
? `${provider.secretKey} (Currently used); leave empty to keep it`
: provider.secretKey,
validate: (value) => (value || existingSecret ? undefined : 'required'),
})
}
return values
}
/** Email step: console logging is the default; MailHog is the one-tap local option. */
export async function promptEmail(vars: Map<string, string>): Promise<Record<string, string>> {
const choice = await p.select({
message: 'Email sending?',
options: [
{
value: 'console',
label: 'None',
hint: 'emails are logged to the console — fine for local',
},
{ value: 'mailhog', label: 'MailHog (local)', hint: 'wires SMTP to localhost:1025' },
{ value: 'resend', label: 'Resend', hint: 'paste an API key' },
{ value: 'smtp', label: 'SMTP', hint: 'any SMTP relay' },
],
initialValue: hasMailProvider(vars) ? (vars.get('SMTP_HOST') ? 'smtp' : 'resend') : 'console',
})
if (choice === 'console') return {}
if (choice === 'mailhog') return { SMTP_HOST: 'localhost', SMTP_PORT: '1025' }
if (choice === 'resend') {
return {
RESEND_API_KEY: await p.password({
message: 'RESEND_API_KEY',
validate: (v) => (v ? undefined : 'required'),
}),
}
}
const values: Record<string, string> = {
SMTP_HOST: await p.text({
message: 'SMTP_HOST',
initialValue: vars.get('SMTP_HOST'),
validate: (v) => (v ? undefined : 'required'),
}),
SMTP_PORT: await p.text({ message: 'SMTP_PORT', initialValue: vars.get('SMTP_PORT') ?? '587' }),
}
const user = await p.text({
message: 'SMTP_USER (empty for unauthenticated relays)',
defaultValue: '',
})
if (user) {
values.SMTP_USER = user
values.SMTP_PASS = await p.password({ message: 'SMTP_PASS' })
const resolvedSecret = secret || existingSecret
if (!resolvedSecret) throw new Error(`${provider.secretKey} was not provided`)
values[provider.secretKey] = resolvedSecret
}
return values
}
+12 -10
View File
@@ -1,3 +1,8 @@
import {
EMAIL_CAPABILITY,
inspectCapability,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
/**
* Server/client feature-flag pairs that must be set together — server code
* reads the bare var, the browser bundle reads the NEXT_PUBLIC_ twin
@@ -51,16 +56,13 @@ export const SELF_HOST_UNLOCKS: ReadonlyArray<{ server: string; label: string; h
},
]
const MAIL_PROVIDER_KEYS = [
'RESEND_API_KEY',
'AWS_SES_REGION',
'SMTP_HOST',
'AZURE_ACS_CONNECTION_STRING',
'GMAIL_CREDENTIALS_JSON',
] as const
export function hasMailProvider(vars: Map<string, string>): boolean {
return MAIL_PROVIDER_KEYS.some((key) => vars.get(key))
export function getConfiguredMailProvider(vars: Map<string, string>): string {
const inspection = inspectCapability(EMAIL_CAPABILITY, vars)
return (
inspection.providerIds[0] ??
inspection.providers.find((provider) => provider.active)?.id ??
'console'
)
}
export const LOGIN_PROVIDERS = [