diff --git a/README.md b/README.md index 2d9b86ca5db..7b2a05134aa 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/bun.lock b/bun.lock index 80ba0853f61..b79e55829ff 100644 --- a/bun.lock +++ b/bun.lock @@ -628,7 +628,7 @@ }, "packages/sim-setup": { "name": "sim-setup", - "version": "1.0.1", + "version": "1.0.2", "bin": { "sim-setup": "dist/index.js", }, diff --git a/packages/deployment-config/src/env-capabilities.ts b/packages/deployment-config/src/env-capabilities.ts index 9a4d473f8f9..2556b1a8fec 100644 --- a/packages/deployment-config/src/env-capabilities.ts +++ b/packages/deployment-config/src/env-capabilities.ts @@ -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] : []), diff --git a/packages/sim-setup/README.md b/packages/sim-setup/README.md index 58200c50182..3b40c8c5fe4 100644 --- a/packages/sim-setup/README.md +++ b/packages/sim-setup/README.md @@ -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 +``` diff --git a/packages/sim-setup/package.json b/packages/sim-setup/package.json index c6e5523474f..fac2cc98ed1 100644 --- a/packages/sim-setup/package.json +++ b/packages/sim-setup/package.json @@ -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": { diff --git a/packages/sim-setup/src/capability-config.ts b/packages/sim-setup/src/capability-config.ts index 8bcbe13cbb0..4848d90607c 100644 --- a/packages/sim-setup/src/capability-config.ts +++ b/packages/sim-setup/src/capability-config.ts @@ -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' }, ] diff --git a/packages/sim-setup/src/capability-status.ts b/packages/sim-setup/src/capability-status.ts index d86772f6b62..5f98e0476f2 100644 --- a/packages/sim-setup/src/capability-status.ts +++ b/packages/sim-setup/src/capability-status.ts @@ -27,7 +27,7 @@ import { } from '@sim/deployment-config/env-capabilities' import { SETUP_FEATURES, type SetupFeatureId } from './capability-config' -export type SetupStatusFeatureId = Exclude +export type SetupStatusFeatureId = Exclude export type CapabilityStatusState = 'default' | 'configured' | 'missing' | 'partial' | 'invalid' export interface CapabilityStatusIssue { diff --git a/packages/sim-setup/src/configuration-sources.test.ts b/packages/sim-setup/src/configuration-sources.test.ts index 5c2e4ce3421..bdd079aa45f 100644 --- a/packages/sim-setup/src/configuration-sources.test.ts +++ b/packages/sim-setup/src/configuration-sources.test.ts @@ -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') diff --git a/packages/sim-setup/src/feature-setup-chat.test.ts b/packages/sim-setup/src/feature-setup-chat.test.ts new file mode 100644 index 00000000000..7eeb63f2c89 --- /dev/null +++ b/packages/sim-setup/src/feature-setup-chat.test.ts @@ -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() + }) +}) diff --git a/packages/sim-setup/src/feature-setup.ts b/packages/sim-setup/src/feature-setup.ts index 3ef0c57eb86..5fbe3e2d8fd 100644 --- a/packages/sim-setup/src/feature-setup.ts +++ b/packages/sim-setup/src/feature-setup.ts @@ -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): Promise { return reconcileLlmSetup(provider, values) } +async function setupChat(vars: Map): Promise> { + 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 ' : 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 = [] diff --git a/packages/sim-setup/src/index.ts b/packages/sim-setup/src/index.ts index 1bc546ede4d..31f63354b14 100644 --- a/packages/sim-setup/src/index.ts +++ b/packages/sim-setup/src/index.ts @@ -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 ' + 'email | storage | sandbox | jobs | cache | knowledge | knowledge-embeddings | chat | llm | integration ' const USAGE = `Usage: sim-setup [--quick] [--dir ] [--mode compose|dev|k8s] diff --git a/packages/sim-setup/src/setup-status.ts b/packages/sim-setup/src/setup-status.ts index ee59e863f1f..cdc2b6b35bc 100644 --- a/packages/sim-setup/src/setup-status.ts +++ b/packages/sim-setup/src/setup-status.ts @@ -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 {