6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
import { browserKeyFlow } from './cli-auth.ts'
|
|
import type { Detection } from './detect.ts'
|
|
import {
|
|
type EnvFile,
|
|
generateSecret,
|
|
isPlaceholder,
|
|
isTruthy,
|
|
isUsableSecret,
|
|
SECRET_KEYS,
|
|
secretRequirement,
|
|
} from './env-files.ts'
|
|
import * as p from './prompter.ts'
|
|
import { link, theme } from './theme.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'
|
|
|
|
/** Reuses existing valid secrets (never regenerates them) and generates the rest. */
|
|
export function collectSecrets(existing: EnvFile): Record<string, string> {
|
|
const secrets: Record<string, string> = {}
|
|
const generated: string[] = []
|
|
const replaced: string[] = []
|
|
for (const key of SECRET_KEYS) {
|
|
const current = existing.vars.get(key)
|
|
if (current && isUsableSecret(key, current)) {
|
|
secrets[key] = current
|
|
} else {
|
|
secrets[key] = generateSecret()
|
|
// A key the app would reject never successfully encrypted anything, so
|
|
// replacing it cannot orphan existing ciphertext.
|
|
if (current && !isPlaceholder(current)) replaced.push(key)
|
|
else generated.push(key)
|
|
}
|
|
}
|
|
if (replaced.length > 0) {
|
|
const detail = replaced.map((key) => `${key} (${secretRequirement(key)})`).join(', ')
|
|
p.log.warn(`Replaced ${detail} — the app rejects the existing value at runtime.`)
|
|
}
|
|
if (generated.length > 0) {
|
|
p.log.step(`Generated ${generated.join(', ')}`)
|
|
}
|
|
return secrets
|
|
}
|
|
|
|
export async function promptCopilotKey(existing?: string): Promise<string | null> {
|
|
if (existing) {
|
|
const keep = await p.confirm({
|
|
message: 'COPILOT_API_KEY is already set — keep it?',
|
|
initialValue: true,
|
|
})
|
|
if (keep) return existing
|
|
}
|
|
p.log.info('Chat is how you talk to Sim — build and manage everything in natural language.')
|
|
const wants = await p.confirm({
|
|
message: 'Generate your Chat API key in the browser?',
|
|
initialValue: true,
|
|
})
|
|
if (!wants) {
|
|
p.log.info(theme.muted('Skipping — the Chat module stays hidden until you re-run setup.'))
|
|
return null
|
|
}
|
|
const key = await browserKeyFlow(process.env.SIM_CLI_AUTH_ORIGIN ?? DEFAULT_CLI_AUTH_ORIGIN)
|
|
if (!key) {
|
|
// Both halves, because the caller writes the opt-out for a null key: a
|
|
// hand-set credential alone restores capability while Chat stays hidden.
|
|
p.log.warn(
|
|
'No key received — re-run bun run setup to retry, or set COPILOT_API_KEY and NEXT_PUBLIC_CHAT_DISABLED=false yourself.'
|
|
)
|
|
return null
|
|
}
|
|
return key
|
|
}
|
|
|
|
/**
|
|
* Hides the Chat module when the user skipped the chat key, so a fresh install
|
|
* gets no Chat surfaces rather than ones that reject every message. Written in
|
|
* both directions on every run, so obtaining a key later un-hides it.
|
|
*
|
|
* Only the wizard writes this. Chat is on by default everywhere else, which is
|
|
* what keeps existing deployments unaffected.
|
|
*/
|
|
export function chatFlagValues(copilotKey: string | null): Record<string, string> {
|
|
return { NEXT_PUBLIC_CHAT_DISABLED: copilotKey ? 'false' : 'true' }
|
|
}
|
|
|
|
/**
|
|
* Escape hatch for Sim devs pointing an install at a non-prod mothership:
|
|
*
|
|
* SIM_CLI_AUTH_ORIGIN=https://www.staging.sim.ai \
|
|
* SIM_AGENT_API_URL=https://www.staging.copilot.sim.ai \
|
|
* bun run setup
|
|
*
|
|
* The two belong together — SIM_CLI_AUTH_ORIGIN decides where the Chat key is
|
|
* minted, SIM_AGENT_API_URL decides which backend validates it, and a key from
|
|
* one environment is rejected by the other. Persisting the URL keeps later
|
|
* `docker compose up` / dev runs on the same backend instead of silently
|
|
* reverting to prod once the shell that exported it is gone.
|
|
*/
|
|
export function mothershipOverride(): Record<string, string> {
|
|
const agentUrl = process.env.SIM_AGENT_API_URL
|
|
const authOrigin = process.env.SIM_CLI_AUTH_ORIGIN
|
|
// Either half alone produces the same cross-environment rejection, just in
|
|
// opposite directions — mint here, validate there. Warning on only one of them
|
|
// would leave the other silent while the copy claims both matter.
|
|
if (authOrigin && !agentUrl) {
|
|
p.log.warn(
|
|
`SIM_CLI_AUTH_ORIGIN mints the Chat key at ${authOrigin}, but SIM_AGENT_API_URL is unset — the app validates against production, which will reject that key. Set both, or neither.`
|
|
)
|
|
} else if (agentUrl && !authOrigin) {
|
|
p.log.warn(
|
|
`SIM_AGENT_API_URL points the app at ${agentUrl}, but SIM_CLI_AUTH_ORIGIN is unset — the Chat key is minted at ${DEFAULT_CLI_AUTH_ORIGIN}, which that backend will reject. Set both, or neither.`
|
|
)
|
|
}
|
|
if (!agentUrl) return {}
|
|
p.log.step(`Using mothership ${agentUrl} (SIM_AGENT_API_URL)`)
|
|
return { SIM_AGENT_API_URL: agentUrl }
|
|
}
|
|
|
|
export async function promptLlmKeys(
|
|
detection: Detection,
|
|
custom: boolean
|
|
): Promise<Record<string, string>> {
|
|
const values: Record<string, string> = {}
|
|
if (detection.shellLlmKeys.length > 0) {
|
|
const adopt = await p.multiselect({
|
|
message: 'Found LLM API keys in your shell — copy into apps/sim/.env?',
|
|
options: detection.shellLlmKeys.map((key) => ({ value: key, label: key })),
|
|
initialValues: detection.shellLlmKeys,
|
|
})
|
|
for (const key of adopt) {
|
|
const value = process.env[key]
|
|
if (!value) throw new Error(`${key} disappeared from the environment mid-run`)
|
|
values[key] = value
|
|
}
|
|
}
|
|
if (detection.ollamaReachable) {
|
|
const useOllama = await p.confirm({
|
|
message: 'Ollama is running on :11434 — wire it up for local models?',
|
|
initialValue: true,
|
|
})
|
|
if (useOllama) values.OLLAMA_URL = 'http://localhost:11434'
|
|
}
|
|
if (custom && Object.keys(values).length === 0 && detection.shellLlmKeys.length === 0) {
|
|
p.log.info(
|
|
theme.muted('No LLM keys configured — you can add keys per-workspace in the UI later (BYOK).')
|
|
)
|
|
}
|
|
return values
|
|
}
|
|
|
|
const PROVIDER_CONSOLES: Record<string, string> = {
|
|
google: 'https://console.cloud.google.com/apis/credentials',
|
|
github: 'https://github.com/settings/developers',
|
|
microsoft: 'https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade',
|
|
}
|
|
|
|
/** Sign-in providers step: credentials in, exact redirect URIs out. */
|
|
export async function promptSignInProviders(
|
|
vars: Map<string, string>,
|
|
appUrl: string
|
|
): Promise<Record<string, string>> {
|
|
const configured = LOGIN_PROVIDERS.filter((prov) => vars.get(prov.idKey)).map((prov) => prov.id)
|
|
const wanted = await p.multiselect({
|
|
message: 'Social sign-in providers? (email/password login works without any)',
|
|
options: LOGIN_PROVIDERS.map((prov) => ({
|
|
value: prov.id,
|
|
label: prov.label,
|
|
hint: configured.includes(prov.id) ? 'Currently used' : undefined,
|
|
})),
|
|
initialValues: configured,
|
|
})
|
|
const values: Record<string, string> = {}
|
|
for (const id of wanted) {
|
|
const provider = LOGIN_PROVIDERS.find((prov) => prov.id === id)
|
|
if (!provider) throw new Error(`unknown provider ${id}`)
|
|
p.log.info(
|
|
`${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}${vars.has(provider.idKey) ? ' (Currently used)' : ''}`,
|
|
initialValue: vars.get(provider.idKey),
|
|
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'),
|
|
})
|
|
const resolvedSecret = secret || existingSecret
|
|
if (!resolvedSecret) throw new Error(`${provider.secretKey} was not provided`)
|
|
values[provider.secretKey] = resolvedSecret
|
|
}
|
|
return values
|
|
}
|
|
|
|
export interface SecurityStepResult {
|
|
sim: Record<string, string>
|
|
mirrorToRealtime: Record<string, string>
|
|
}
|
|
|
|
/** Auth loosening + admin key. DISABLE_AUTH must reach BOTH env files. */
|
|
export async function promptSecurity(vars: Map<string, string>): Promise<SecurityStepResult> {
|
|
const sim: Record<string, string> = {}
|
|
const mirrorToRealtime: Record<string, string> = {}
|
|
|
|
const disableAuth = await p.confirm({
|
|
message: 'Disable auth entirely? (anonymous access — ONLY for a private network)',
|
|
initialValue: isTruthy(vars.get('DISABLE_AUTH')),
|
|
})
|
|
if (disableAuth) {
|
|
p.log.warn('Anyone who can reach this instance has full access. Never expose it publicly.')
|
|
sim.DISABLE_AUTH = 'true'
|
|
mirrorToRealtime.DISABLE_AUTH = 'true'
|
|
}
|
|
|
|
const privateHosts = await p.confirm({
|
|
message:
|
|
'Allow DB/connector tools to reach private hosts? (Docker/K8s service names, localhost — loosens the SSRF guard)',
|
|
initialValue: isTruthy(vars.get('ALLOW_PRIVATE_DATABASE_HOSTS')),
|
|
})
|
|
if (privateHosts) sim.ALLOW_PRIVATE_DATABASE_HOSTS = 'true'
|
|
|
|
const existingAdminKey = vars.get('ADMIN_API_KEY')
|
|
if (!existingAdminKey || isPlaceholder(existingAdminKey)) {
|
|
const wantsAdmin = await p.confirm({
|
|
message: 'Generate an ADMIN_API_KEY? (enables the admin API for workflow export/import)',
|
|
initialValue: false,
|
|
})
|
|
if (wantsAdmin) {
|
|
sim.ADMIN_API_KEY = generateSecret()
|
|
p.log.step('Generated ADMIN_API_KEY')
|
|
}
|
|
}
|
|
return { sim, mirrorToRealtime }
|
|
}
|
|
|
|
/** Self-host feature unlocks — always writes BOTH members of each twin pair. */
|
|
export async function promptUnlocks(vars: Map<string, string>): Promise<Record<string, string>> {
|
|
const selected = await p.multiselect({
|
|
message: 'Unlock self-host features? (bypasses hosted plan gating)',
|
|
options: SELF_HOST_UNLOCKS.map((unlock) => ({
|
|
value: unlock.server,
|
|
label: unlock.label,
|
|
hint: unlock.hint || undefined,
|
|
})),
|
|
initialValues: SELF_HOST_UNLOCKS.filter((u) => isTruthy(vars.get(u.server))).map(
|
|
(u) => u.server
|
|
),
|
|
})
|
|
if (selected.length === 0) return {}
|
|
const flags = new Set(selected)
|
|
if (flags.has('ENTERPRISE_ENABLED')) {
|
|
p.log.info(
|
|
theme.muted(
|
|
'The enterprise switch covers every feature below — pick individual ones only to override it.'
|
|
)
|
|
)
|
|
}
|
|
if (flags.has('ACCESS_CONTROL_ENABLED') && !flags.has('ORGANIZATIONS_ENABLED')) {
|
|
flags.add('ORGANIZATIONS_ENABLED')
|
|
p.log.info(theme.muted('Access control requires organizations — enabling both.'))
|
|
}
|
|
const values: Record<string, string> = {}
|
|
for (const server of flags) {
|
|
values[server] = 'true'
|
|
const twin = FLAG_TWINS.find((pair) => pair.server === server)
|
|
if (twin) values[twin.client] = 'true'
|
|
}
|
|
return values
|
|
}
|