Files
sim/scripts/setup/steps.ts
T
Theodore LiandClaude Opus 5 c5cc6ce26c feat(chat): hide the Chat module when NEXT_PUBLIC_CHAT_DISABLED is set (#6137)
* feat(chat): hide the Chat module when CHAT_ENABLED is unset

A self-hosted deployment that skipped the chat key still rendered the full
mothership Chat UI, landing on the composer and 401ing on every message.

Gate it behind a CHAT_ENABLED / NEXT_PUBLIC_CHAT_ENABLED twin, written by the
setup wizard alongside COPILOT_API_KEY and validated by the existing FLAG_TWINS
doctor check. The flag resolves at module scope on both render passes, so no
chat surface renders then disappears.

With Chat off the workspace lands on its first workflow (resolved server-side,
behind the cached host-context check so no workflow id leaks to non-members),
and the chats list, scheduled tasks, editor Chat panel, and chat CTAs are
absent. Routes are gated rather than deleted: /home redirects because it is
baked into delivered invitation emails and the accept contract.

Also fixes two bugs the gate exposed: a persisted activeTab of 'copilot' left
the workflow panel blank from first paint, and the panel's handoff listener
claimed MOTHERSHIP_SEND_MESSAGE events outside its own gate, silently
swallowing "Fix in Chat" messages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha

* refactor(chat): gate the UI on NEXT_PUBLIC_CHAT_DISABLED, not an opt-in flag

CHAT_ENABLED made Chat opt-in, so every existing deployment that already had
COPILOT_API_KEY would have lost the module until it set a new variable. Invert
to an opt-out so nothing changes for them.

That also collapses the twin. The only reason the flag needed a server/client
pair was that it projected a secret; NEXT_PUBLIC_CHAT_DISABLED is not one, so
getEnv resolves the same value from process.env on the server and window.__ENV
in the browser. Gone with it: the FLAG_TWINS entry and its doctor sync check,
the two-variable wizard write, and the boot-time throw, whose contradiction
(flag on, key absent) can no longer be expressed.

Presentation and capability are now separate concerns. NEXT_PUBLIC_CHAT_DISABLED
decides whether the surfaces render; COPILOT_API_KEY decides whether the work
can run, and gates the paths that need it — the Sim Chat block, prompt-job
claims, and inbox access — each failing on its own terms.

The wizard writes the opt-out when you skip the chat key, which is the case this
started from: a fresh self-host that never configured Chat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha

* feat(setup): prompt for the chat key in k8s mode

The dev and compose flows minted a chat key and wrote the Chat opt-out
alongside it; k8s did neither, so a cluster install with no COPILOT_API_KEY in
its Helm values rendered a Chat module that rejects every message.

Prompt with the same flow and feed both values into `app.env`, which the chart
already renders as arbitrary container env. Reading the previous release's key
matters here in a way it does not for the file-based modes: `helm upgrade`
without `--reuse-values` keeps only what this document carries, so a key the
user elects to keep has to be re-supplied or it is silently dropped.

Splits the release-values read from the secret-reuse check so both the key and
the secrets come from one `helm get values` call, and carries the mothership
override across for the same mint-here-validate-there reason the other modes
document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha

* fix(setup): write app-behavior flags to every env file the app can start from

The wizard wrote the Chat opt-out only to the env file its own mode owns, so
choosing compose put it in the root `.env` while `bun run dev` reads
`apps/sim/.env` and never saw it. Skipping the chat key appeared to do nothing.

Mirror values that change how the app behaves — as opposed to where it connects
— across both targets. Connection settings deliberately do not go through this:
DATABASE_URL and friends differ between the compose stack and a local dev run,
which is why this takes an explicit set of values rather than the whole batch.

The mirrored file is written even when absent, since missing is exactly the case
that stranded the flag, but with seeding suppressed so a compose run leaves a
one-line apps/sim/.env instead of a full .env.example for a stack the user is
not running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha

* fix(compose): forward NEXT_PUBLIC_CHAT_DISABLED to the app container

The wizard wrote the flag into the root .env, but compose only passes through
variables the service's `environment` block names — and that block listed
COPILOT_API_KEY without its companion. Skipping the chat key on a Docker install
therefore did nothing: the value sat in .env and never reached the container.

Add the passthrough to all four compose files. Reverts the previous commit's
mirroring into apps/sim/.env, which treated the symptom — each mode writes only
the env file it owns, and that file is now wired correctly.

k8s needs no equivalent: its values flow into `app.env`, which the chart renders
key by key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha

* fix(chat): resolve the landing route without blocking on the database

Server-resolving the first workflow meant a session lookup, an access check and
a query had to finish before anything rendered. A slow or unreachable database
left the user on a blank page under a populated sidebar — worse than the
instant redirect it replaced, and with no signal that anything was wrong.

Redirect straight to `/w` instead and let it pick from the workflow list the
layout already prefetches, so the choice costs no round trip and cannot hang.

Repoints the sidebar's primary action rather than hiding it: the slot that
offered "New chat" now offers "New workflow" and creates one, since with Chat
off there is no composer to open but the intent is the same.

Sends the CLI key handoff to signup rather than login. It is reached from a
terminal — usually the setup wizard standing up a fresh self-host — where the
visitor has no account yet. Both auth pages cross-link carrying the callback,
so a returning user is one click from login with their destination intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha

* improvement(chat): address cleanup-pass findings on the Chat gate

Effects: the panel's auto-select effect read the copilot chat list while the
list query was deliberately skipped, took "empty" for "deleted in another tab",
and cleared the user's selection — latching a ref that stopped it ever being
restored. Guarded on the same condition as the handoff listener.

Memo: `/w` filtered workflows through a useMemo whose array dependency was a
fresh `[]` on every render while the query had no data — the exact window the
page exists for — so it memoized nothing and re-fired the redirect effect. Keyed
on the workflow id instead. Same unstable-default problem on the sidebar's chat
list, where it invalidated five downstream memos; given a stable empty constant.

Callback: `handleCreateWorkflow` listed the whole mutation object in its deps,
which TanStack recreates every render. Harmless until this branch wired it into
the top nav, where it defeated `memo(SidebarNavItem)`.

React Query: Recently Deleted still fetched archived chats unconditionally and
offered restores into routes that now 404.

Also surfaces an error state on `/w` — it is the landing route now, so a failed
list fetch would otherwise spin forever behind a log line — fixes a spinner
using a token undefined in dark mode, and trims comments that restated code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha

* fix(chat): gate workflow creation on write access, pin the key in schedule tests

The zero-workflow landing offered "Create workflow" to every member. Creation
navigates optimistically, so a read-only member was sent to a workflow the
server had already refused to create, with the failure never surfaced. Gate both
entry points — the empty state and the sidebar's "New workflow" row — on the
same `canEdit` check the rest of the sidebar uses, and tell read-only members
who can make one instead of offering an action that cannot succeed.

The schedule-execution tests only passed locally because vitest loads the
developer's own `.env`, which supplied COPILOT_API_KEY; CI has none, so the
prompt-job claim guard skipped the claims those cases assert on. Pin the key
through the env mock so the suite states its own preconditions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha

* fix(setup): name both variables in the chat-key failure hint

The caller writes the Chat opt-out whenever the prompt returns no key, so the
hint's "or set COPILOT_API_KEY yourself" restored capability while leaving the
module hidden — the one path where following setup's own advice does not work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:23:24 -04:00

423 lines
16 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, hasMailProvider, 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
}
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',
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) ? 'already configured' : 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,
initialValue: vars.get(provider.idKey),
validate: (v) => (v ? undefined : 'required'),
})
values[provider.secretKey] = await p.password({
message: provider.secretKey,
validate: (v) => (v ? 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' })
}
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
}