improvement(setup): complete knowledge and update flows (#6521)

This commit is contained in:
Theodore Li
2026-08-10 23:38:47 -04:00
committed by GitHub
parent daac4f38d4
commit 5478a690cc
15 changed files with 219 additions and 25 deletions
+7
View File
@@ -10,6 +10,7 @@ import {
defineCapabilitySetup,
EMAIL_SETUP,
getOAuthClientSetupFields,
KNOWLEDGE_EMBEDDINGS_SETUP,
STORAGE_SETUP,
} from './capability-config.ts'
import { getCapabilitySetupOptions } from './capability-setup.ts'
@@ -76,6 +77,12 @@ describe('capability setup configuration', () => {
)
})
it('offers OpenAI first for fresh knowledge embedding setup', () => {
expect(
getCapabilitySetupOptions(KNOWLEDGE_EMBEDDINGS_SETUP).map((option) => option.id)
).toEqual(['openai', 'azure-openai', 'openrouter'])
})
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
+1 -1
View File
@@ -823,7 +823,7 @@ export const KNOWLEDGE_EMBEDDINGS_SETUP = defineCapabilitySetup(KNOWLEDGE_EMBEDD
],
},
},
optionOrder: ['azure-openai', 'openai', 'openrouter'],
optionOrder: ['openai', 'azure-openai', 'openrouter'],
})
export const CAPABILITY_SETUPS = [
+62 -12
View File
@@ -49,6 +49,8 @@ interface PromptState {
values: Record<string, string>
}
const SKIP_OPTION_ID = '__skip-capability-setup__'
/** Stages a capability transition into a larger setup run without losing prompt context. */
export function stageCapabilitySetupTransition(
currentValues: Map<string, string>,
@@ -176,11 +178,11 @@ export function getCapabilitySetupOptions(
})
}
/** Resolves the setup option representing the effective current configuration. */
/** Resolves the setup option representing the effective current configuration, if one exists. */
export function resolveCurrentCapabilitySetupOptionId(
setup: CapabilitySetupDefinition,
values: EnvCapabilityValues
): string {
): string | undefined {
const options = getCapabilitySetupOptions(setup)
const explicitAction = options.find(
(option) =>
@@ -214,9 +216,10 @@ export function resolveCurrentCapabilitySetupOptionId(
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`
)
if (options.length === 0) {
throw new Error(`Capability ${setup.definition.id} has no setup options`)
}
return undefined
}
/** Applies selector and activation inference to the CLI-entered values. */
@@ -473,6 +476,21 @@ async function renderPrompts(prompts: readonly SetupPrompt[], state: PromptState
}
}
async function promptSelectedCapabilitySetup(
setup: CapabilitySetupDefinition,
selected: ResolvedSetupOption,
currentValues: ReadonlyMap<string, string>
): Promise<EnvCapabilitySetupTransition> {
const state: PromptState = {
setup,
optionId: selected.id,
currentValues,
values: {},
}
await renderPrompts(selected.prompts, state)
return buildCapabilitySetupTransition(setup, selected.id, state.values, currentValues)
}
/** Renders a CLI-owned capability setup and returns its validated environment transition. */
export async function promptCapabilitySetup(
setup: CapabilitySetupDefinition,
@@ -497,12 +515,44 @@ export async function promptCapabilitySetup(
)
}
const state: PromptState = {
setup,
optionId: selected.id,
currentValues,
values: {},
return promptSelectedCapabilitySetup(setup, selected, currentValues)
}
/** Offers capability providers while allowing the user to leave configuration unchanged. */
export async function promptOptionalCapabilitySetup(
setup: CapabilitySetupDefinition,
currentValues: ReadonlyMap<string, string>,
context: CapabilitySetupContext,
skipHint: string
): Promise<EnvCapabilitySetupTransition | null> {
const options = getCapabilitySetupOptions(setup)
if (options.some((option) => option.id === SKIP_OPTION_ID)) {
throw new Error(`Capability ${setup.definition.id} uses reserved option ${SKIP_OPTION_ID}`)
}
await renderPrompts(selected.prompts, state)
return buildCapabilitySetupTransition(setup, selected.id, state.values, currentValues)
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),
})),
{
value: SKIP_OPTION_ID,
label: 'Not now',
hint: currentOptionId ? 'leave the current configuration unchanged' : skipHint,
},
],
initialValue: currentOptionId ?? options[0]?.id,
})
if (selectedOptionId === SKIP_OPTION_ID) return null
const selected = options.find((option) => option.id === selectedOptionId)
if (!selected) {
throw new Error(
`Capability ${setup.definition.id} returned unknown setup option ${selectedOptionId}`
)
}
return promptSelectedCapabilitySetup(setup, selected, currentValues)
}
+1
View File
@@ -18,6 +18,7 @@ const USAGE = `Usage:
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 update pull/rebuild and apply Compose images
bun run sim status what's installed and healthy
bun run sim logs follow logs
bun run sim down remove containers (data kept)
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'bun:test'
import { getComposeUpdateMode, isLifecycleCommand } from './lifecycle.ts'
describe('setup lifecycle', () => {
it('recognizes update as a lifecycle command', () => {
expect(isLifecycleCommand('update')).toBe(true)
})
it('pulls published installs and rebuilds source installs', () => {
expect(getComposeUpdateMode('/repo/docker-compose.prod.yml')).toBe('pull')
expect(getComposeUpdateMode('/repo/docker-compose.local.yml')).toBe('build')
expect(() => getComposeUpdateMode('/repo/compose.yml')).toThrow(/Unsupported Sim Compose file/)
})
})
+47
View File
@@ -20,6 +20,7 @@ export const LIFECYCLE_COMMANDS = [
'start',
'stop',
'restart',
'update',
'status',
'logs',
'down',
@@ -325,6 +326,50 @@ function restart(install: Install): void {
p.note(k8sReachHints(install.context), 'Kubernetes is managed with kubectl')
}
export type ComposeUpdateMode = 'pull' | 'build'
/** Resolves how a setup-managed Compose install obtains its next image. */
export function getComposeUpdateMode(file: string): ComposeUpdateMode {
const name = path.basename(file)
if (name === 'docker-compose.prod.yml') return 'pull'
if (name === 'docker-compose.local.yml') return 'build'
throw new Error(`Unsupported Sim Compose file: ${file}`)
}
function update(install: Install): void {
if (install.kind === 'dev') {
throw new SetupError('sim update is only available for Docker Compose installs.', [
'update the source checkout with git, run bun install, then restart bun run dev:full',
])
}
if (install.kind === 'k8s') {
throw new SetupError('sim update does not upgrade Kubernetes releases.', [
'upgrade the release with helm after reviewing the chart and release notes',
])
}
const mode = getComposeUpdateMode(install.file)
const spin = p.spinner()
if (mode === 'pull') {
spin.start('Pulling configured Sim images…')
dockerRun(composeArgs(install, 'pull'), 'docker compose pull failed', install.dir)
} else {
spin.start('Rebuilding Sim images with current base images…')
dockerRun(composeArgs(install, 'build', '--pull'), 'docker compose build failed', install.dir)
}
spin.message('Applying updated images and running migrations…')
dockerRun(composeArgs(install, 'up', '-d'), 'docker compose up failed', install.dir)
spin.stop('Sim updated (data volumes kept)')
p.note(
[
`version: ${theme.command(`SIM_VERSION in ${path.join(install.dir, '.env')}`)} (latest when unset)`,
`check: ${theme.command('bun run sim status')}`,
`logs: ${theme.command('bun run sim logs')}`,
].join('\n'),
'Update complete'
)
}
function showLogs(install: Install): void {
if (install.kind === 'compose') {
dockerInherit(composeArgs(install, 'logs', '-f', '--tail', '100'), install.dir)
@@ -488,6 +533,8 @@ export async function runLifecycle(command: LifecycleCommand): Promise<void> {
return stop(install)
case 'restart':
return restart(install)
case 'update':
return update(install)
case 'logs':
return showLogs(install)
case 'down':
+7 -2
View File
@@ -13,6 +13,7 @@ import {
collectSecrets,
mothershipOverride,
promptCopilotKey,
promptKnowledgeEmbeddings,
promptLlmKeys,
promptSecurity,
promptSignInProviders,
@@ -117,9 +118,13 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom
if (copilotKey) values.COPILOT_API_KEY = copilotKey
Object.assign(values, chatFlagValues(copilotKey))
Object.assign(values, await promptLlmKeys(detection, !quick))
const stagedVars = new Map(root.vars)
for (const [key, value] of Object.entries(values)) stagedVars.set(key, value)
const embeddings = await promptKnowledgeEmbeddings(stagedVars, { containerized: true })
if (embeddings) {
stageCapabilitySetupTransition(stagedVars, values, remove, embeddings)
}
if (!quick) {
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,
})
+8 -2
View File
@@ -15,6 +15,7 @@ import {
collectSecrets,
mothershipOverride,
promptCopilotKey,
promptKnowledgeEmbeddings,
promptLlmKeys,
promptSecurity,
promptSignInProviders,
@@ -120,9 +121,14 @@ export async function runDevMode(
writeEnvValues('realtime', { REDIS_URL: redisUrl })
}
const stagedVars = new Map(simAfter.vars)
for (const [key, value] of Object.entries(values)) stagedVars.set(key, value)
const embeddings = await promptKnowledgeEmbeddings(stagedVars, { containerized: false })
if (embeddings) {
stageCapabilitySetupTransition(stagedVars, values, remove, embeddings)
}
if (!quick) {
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,
+19 -1
View File
@@ -1,12 +1,19 @@
import { spawn, spawnSync } from 'node:child_process'
import { getErrorMessage } from '@sim/utils/errors'
import { KNOWLEDGE_EMBEDDINGS_SETUP } from '../capability-config.ts'
import { getCapabilitySetupFields, stageCapabilitySetupTransition } from '../capability-setup.ts'
import type { Detection } from '../detect.ts'
import { ensureDocker } from '../docker.ts'
import { generateSecret, ROOT } from '../env-files.ts'
import { SetupError } from '../errors.ts'
import { waitFor } from '../probes.ts'
import * as p from '../prompter.ts'
import { chatFlagValues, mothershipOverride, promptCopilotKey } from '../steps.ts'
import {
chatFlagValues,
mothershipOverride,
promptCopilotKey,
promptKnowledgeEmbeddings,
} from '../steps.ts'
import { glyph, theme } from '../theme.ts'
import { APP_SIGNUP_URL, APP_URL } from '../urls.ts'
@@ -362,6 +369,17 @@ export async function runK8sMode(detection: Detection): Promise<void> {
...(copilotKey ? { COPILOT_API_KEY: copilotKey } : {}),
...chatFlagValues(copilotKey),
}
const stagedVars = new Map(Object.entries(releaseValues?.app?.env ?? {}))
for (const [key, value] of Object.entries(appEnv)) stagedVars.set(key, value)
for (const key of getCapabilitySetupFields(KNOWLEDGE_EMBEDDINGS_SETUP)) {
const existing = stagedVars.get(key)
if (existing) appEnv[key] = existing
}
const remove = new Set<string>()
const embeddings = await promptKnowledgeEmbeddings(stagedVars, { containerized: true })
if (embeddings) {
stageCapabilitySetupTransition(stagedVars, appEnv, remove, embeddings)
}
const spin = p.spinner()
spin.start('helm upgrade --install (first run pulls images — this can take several minutes)…')
+25 -1
View File
@@ -2,11 +2,12 @@ import { describe, expect, it } from 'bun:test'
import {
EMAIL_CAPABILITY,
inspectCapability,
KNOWLEDGE_EMBEDDINGS_CAPABILITY,
requireCapability,
STORAGE_CAPABILITY,
validateCapabilityFieldInput,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { EMAIL_SETUP, STORAGE_SETUP } from './capability-config.ts'
import { EMAIL_SETUP, KNOWLEDGE_EMBEDDINGS_SETUP, STORAGE_SETUP } from './capability-config.ts'
import {
buildCapabilitySetupTransition,
resolveCurrentCapabilitySetupOptionId,
@@ -41,6 +42,15 @@ describe('setup provider reconciliation', () => {
).toBe('smtp')
})
it('allows setup when no knowledge embedding provider is configured', () => {
expect(resolveCurrentCapabilitySetupOptionId(KNOWLEDGE_EMBEDDINGS_SETUP, {})).toBeUndefined()
expect(
resolveCurrentCapabilitySetupOptionId(KNOWLEDGE_EMBEDDINGS_SETUP, {
OPENROUTER_API_KEY: 'openrouter-key',
})
).toBe('openrouter')
})
it('uses canonical storage selection for setup defaults', () => {
expect(
resolveCurrentCapabilitySetupOptionId(STORAGE_SETUP, new Map([['AWS_REGION', 'us-east-1']]))
@@ -79,6 +89,20 @@ describe('setup provider reconciliation', () => {
expect(inspectCapability(EMAIL_CAPABILITY, reconciled).providerIds).toEqual(['resend', 'smtp'])
})
it('configures a knowledge embedding provider from an empty state', () => {
const result = buildCapabilitySetupTransition(
KNOWLEDGE_EMBEDDINGS_SETUP,
'openrouter',
{ OPENROUTER_API_KEY: 'openrouter-key' },
{}
)
const reconciled = applyResult({}, result)
expect(inspectCapability(KNOWLEDGE_EMBEDDINGS_CAPABILITY, reconciled).providerIds).toEqual([
'openrouter',
])
})
it('clears stale SMTP auth for an unauthenticated relay', () => {
const result = buildCapabilitySetupTransition(
EMAIL_SETUP,
+19
View File
@@ -1,3 +1,9 @@
import { KNOWLEDGE_EMBEDDINGS_SETUP } from './capability-config.ts'
import {
type CapabilitySetupContext,
type EnvCapabilitySetupTransition,
promptOptionalCapabilitySetup,
} from './capability-setup.ts'
import { browserKeyFlow } from './cli-auth.ts'
import type { Detection } from './detect.ts'
import {
@@ -149,6 +155,19 @@ export async function promptLlmKeys(
return values
}
/** Configures knowledge embeddings while allowing the user to explicitly defer them. */
export function promptKnowledgeEmbeddings(
currentValues: ReadonlyMap<string, string>,
context: CapabilitySetupContext
): Promise<EnvCapabilitySetupTransition | null> {
return promptOptionalCapabilitySetup(
KNOWLEDGE_EMBEDDINGS_SETUP,
currentValues,
context,
'knowledge-base indexing and semantic search will remain unavailable'
)
}
const PROVIDER_CONSOLES: Record<string, string> = {
google: 'https://console.cloud.google.com/apis/credentials',
github: 'https://github.com/settings/developers',
+1 -1
View File
@@ -163,7 +163,7 @@ export async function runWizard(flags: WizardFlags): Promise<void> {
p.note(
[
mode === 'k8s' ? `port-forward, then open ${APP_SIGNUP_URL}` : `open ${APP_SIGNUP_URL}`,
'manage it: bun run sim start · stop · status · logs',
'manage it: bun run sim start · stop · update · status · logs',
'check your setup: bun run sim doctor',
mode === 'dev' && !startDevNow ? `start Sim: bun run ${devScript}` : null,
`prefer a bare "sim"? ${theme.command('bun link')} once (needs ~/.bun/bin on PATH)`,