feat(setup): add incremental Chat setup (#7043)

* feat(setup): add incremental Chat setup

* fix(setup): retain existing Compose Chat config
This commit is contained in:
Theodore Li
2026-08-24 16:22:42 -07:00
committed by GitHub
parent 49e2bb5da7
commit 7b761ba560
12 changed files with 142 additions and 6 deletions
+1
View File
@@ -88,6 +88,7 @@ npx sim-setup add sandbox
npx sim-setup add jobs
npx sim-setup add cache
npx sim-setup add knowledge
npx sim-setup add chat
npx sim-setup add llm
npx sim-setup add integration slack
```
+1 -1
View File
@@ -630,7 +630,7 @@
},
"packages/sim-setup": {
"name": "sim-setup",
"version": "1.0.1",
"version": "1.0.2",
"bin": {
"sim-setup": "dist/index.js",
},
@@ -1339,9 +1339,12 @@ export const DEPLOYMENT_CONFIGURATION_KEYS: readonly string[] = [
...new Set([
...CORE_CONFIGURATION_KEYS,
...ENV_CAPABILITIES.flatMap(capabilityKeys),
'COPILOT_API_KEY',
'EMAIL_VERIFICATION_ENABLED',
'NEXT_PUBLIC_CHAT_DISABLED',
'NEXT_PUBLIC_E2B_ENABLED',
'NEXT_PUBLIC_SANDBOXES_ENABLED',
'SIM_AGENT_API_URL',
...Object.values(LLM_KEY_POOLS).flatMap((pool) => [
...pool.keys,
...('fallbackKey' in pool ? [pool.fallbackKey] : []),
+6
View File
@@ -9,3 +9,9 @@ npx sim-setup
Outside a Sim source checkout, the command creates a Docker Compose installation using published
images. Inside a Sim source checkout, use `bun run sim-setup` to expose the complete development
and deployment wizard.
To connect or replace the Chat API key without rerunning the full wizard:
```bash
npx sim-setup add chat
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sim-setup",
"version": "1.0.1",
"version": "1.0.2",
"description": "Set up and manage a self-hosted Sim installation",
"type": "module",
"bin": {
+2 -1
View File
@@ -847,13 +847,14 @@ if (missingCapabilitySetups.length > 0) {
}
export type CapabilitySetupId = (typeof CAPABILITY_SETUPS)[number]['definition']['id']
export type SetupFeatureId = CapabilitySetupId | 'llm' | 'integration'
export type SetupFeatureId = CapabilitySetupId | 'chat' | 'llm' | 'integration'
export const SETUP_FEATURES: readonly { id: SetupFeatureId; label: string }[] = [
...CAPABILITY_SETUPS.map((setup) => ({
id: setup.definition.id,
label: setup.label,
})),
{ id: 'chat', label: 'Chat' },
{ id: 'llm', label: 'LLM API keys' },
{ id: 'integration', label: 'OAuth integration' },
]
+1 -1
View File
@@ -27,7 +27,7 @@ import {
} from '@sim/deployment-config/env-capabilities'
import { SETUP_FEATURES, type SetupFeatureId } from './capability-config'
export type SetupStatusFeatureId = Exclude<SetupFeatureId, 'integration'>
export type SetupStatusFeatureId = Exclude<SetupFeatureId, 'chat' | 'integration'>
export type CapabilityStatusState = 'default' | 'configured' | 'missing' | 'partial' | 'invalid'
export interface CapabilityStatusIssue {
@@ -86,6 +86,29 @@ describe('discoverConfigurationSources', () => {
expect(sources[0].values?.get('RESEND_API_KEY')).toBe('current')
})
it('retains Chat configuration from a prepared Compose .env file', () => {
const root = temporaryDirectory()
writeFileSync(
path.join(root, '.env'),
[
'COPILOT_API_KEY=existing-chat-key',
'NEXT_PUBLIC_CHAT_DISABLED=false',
'SIM_AGENT_API_URL=https://copilot.example.com',
].join('\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('COPILOT_API_KEY')).toBe('existing-chat-key')
expect(sources[0].values?.get('NEXT_PUBLIC_CHAT_DISABLED')).toBe('false')
expect(sources[0].values?.get('SIM_AGENT_API_URL')).toBe('https://copilot.example.com')
})
it('uses the effective environment of a stopped Compose app container', () => {
const parent = temporaryDirectory()
const root = path.join(parent, 'checkout')
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockDiscoverConfigurationSources,
mockReconcileEnvValues,
mockPromptCopilotKey,
mockMothershipOverride,
mockChatFlagValues,
mockOutro,
} = vi.hoisted(() => ({
mockDiscoverConfigurationSources: vi.fn(),
mockReconcileEnvValues: vi.fn(),
mockPromptCopilotKey: vi.fn(),
mockMothershipOverride: vi.fn(),
mockChatFlagValues: vi.fn(),
mockOutro: vi.fn(),
}))
vi.mock('./configuration-sources', () => ({
discoverConfigurationSources: mockDiscoverConfigurationSources,
}))
vi.mock('./env-files', () => ({
reconcileEnvValues: mockReconcileEnvValues,
}))
vi.mock('./prompter', () => ({
outro: mockOutro,
}))
vi.mock('./steps', () => ({
chatFlagValues: mockChatFlagValues,
mothershipOverride: mockMothershipOverride,
promptCopilotKey: mockPromptCopilotKey,
}))
vi.mock('./theme', () => ({
theme: { accent: (value: string) => value },
}))
import { runFeatureSetup } from './feature-setup'
describe('Chat feature setup', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDiscoverConfigurationSources.mockReturnValue([
{
kind: 'compose',
label: 'Docker Compose',
location: '.env',
values: new Map([['COPILOT_API_KEY', 'existing-key']]),
managedByCurrentCheckout: true,
},
])
mockMothershipOverride.mockReturnValue({
SIM_AGENT_API_URL: 'https://copilot.example.com',
})
mockChatFlagValues.mockReturnValue({ NEXT_PUBLIC_CHAT_DISABLED: 'false' })
})
it('writes only the Chat configuration to the detected install', async () => {
mockPromptCopilotKey.mockResolvedValue('new-key')
await runFeatureSetup('chat', [])
expect(mockPromptCopilotKey).toHaveBeenCalledWith('existing-key')
expect(mockReconcileEnvValues).toHaveBeenCalledWith('root', [], {
COPILOT_API_KEY: 'new-key',
NEXT_PUBLIC_CHAT_DISABLED: 'false',
SIM_AGENT_API_URL: 'https://copilot.example.com',
})
expect(mockOutro).toHaveBeenCalledWith(
'Chat written to .env. Recreate the app container for it to take effect.'
)
})
it('fails without changing configuration when no key is received', async () => {
mockPromptCopilotKey.mockResolvedValue(null)
await expect(runFeatureSetup('chat', [])).rejects.toThrow(
'Chat setup did not receive an API key. No configuration was changed.'
)
expect(mockReconcileEnvValues).not.toHaveBeenCalled()
})
})
+17
View File
@@ -13,6 +13,7 @@ import { promptCapabilitySetup } from './capability-setup'
import { type ConfigurationSource, discoverConfigurationSources } from './configuration-sources'
import { type EnvTarget, reconcileEnvValues } from './env-files'
import * as p from './prompter'
import { chatFlagValues, mothershipOverride, promptCopilotKey } from './steps'
import { theme } from './theme'
function isSetupFeatureId(value: string): value is SetupFeatureId {
@@ -113,6 +114,19 @@ async function setupLlm(vars: Map<string, string>): Promise<LlmSetupResult> {
return reconcileLlmSetup(provider, values)
}
async function setupChat(vars: Map<string, string>): Promise<Record<string, string>> {
const overrides = mothershipOverride()
const copilotKey = await promptCopilotKey(vars.get('COPILOT_API_KEY'))
if (!copilotKey) {
throw new Error('Chat setup did not receive an API key. No configuration was changed.')
}
return {
...overrides,
COPILOT_API_KEY: copilotKey,
...chatFlagValues(copilotKey),
}
}
export function setupFeatureUsage(): string {
return SETUP_FEATURES.map((feature) =>
feature.id === 'integration' ? 'integration <slug>' : feature.id
@@ -182,6 +196,9 @@ export async function runFeatureSetup(feature: string, args: readonly string[]):
})
values = result.values
remove = result.remove
} else if (feature === 'chat') {
values = await setupChat(vars)
remove = []
} else if (feature === 'integration') {
values = await setupIntegration(args[0], vars)
remove = []
+1 -1
View File
@@ -7,7 +7,7 @@ import { theme } from './theme'
import { SETUP_VERSION } from './version'
const SETUP_FEATURES =
'email | storage | sandbox | jobs | cache | knowledge | knowledge-embeddings | llm | integration <slug>'
'email | storage | sandbox | jobs | cache | knowledge | knowledge-embeddings | chat | llm | integration <slug>'
const USAGE = `Usage:
sim-setup [--quick] [--dir <path>] [--mode compose|dev|k8s]
+1 -1
View File
@@ -38,7 +38,7 @@ export interface SetupStatusReport {
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]
feature.id === 'chat' || feature.id === 'integration' ? [] : [feature.id]
)
function readString(values: EnvCapabilityValues, key: string): string | undefined {