mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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()
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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-')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.`
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
Executable
+52
@@ -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()
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user