mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
* feat(setup): setup wizard with browser-based Chat key handoff
Adds `bun run setup` and `bun run doctor` for local installs, and replaces
the wizard's paste-your-Chat-key step with a browser handoff that never puts
the key in a URL.
* improvement(setup): drop the paste-a-key fallback, simplify consent copy
The browser handoff is now the only path — the wizard waits on a spinner
instead of racing a paste prompt. Consent card leads with "Connect your
terminal" and moves the match-the-code disclaimer into the description.
* fix(setup): pin kube context, keep secrets out of argv, validate reused keys
Review findings from #5911:
- helm/kubectl now run against the validated context instead of the ambient one
- helm values are piped on stdin rather than passed as --set arguments
- ENCRYPTION_KEY/API_ENCRYPTION_KEY are checked for the 64-hex format the app
requires, not just length, so an unusable key is replaced rather than kept
- the managed Redis container's published port is read back instead of assumed
* refactor(copilot): one module for Chat API key operations
list/generate/delete each repeated the same /api/validate-key envelope in
their route. They now share callValidateKey in lib/copilot/server/api-keys.ts,
which also keeps the display masking server-side so the full key can only ever
leave at creation.
* improvement(setup): reuse shared helpers, parallelize probes, drop dead code
- PKCE verifier/state/pairing code now use generateSecureToken, generateRandomHex
and generateShortId instead of hand-rolled randomBytes; the pairing loop's
modulo was unbiased only because 256 % 32 == 0
- new sha256Base64Url in @sim/security/hash so both sides of the PKCE exchange
derive the challenge from one implementation
- isUsableSecret moved beside SECRET_KEYS so setup and doctor apply the same
rule; doctor previously passed a key setup would replace
- isTruthy narrowed to true/1, matching the app it claims to mirror — it accepted
yes/on, so a flag could read on in doctor and off in the app
- checkLive runs its five probes concurrently (~17s serial worst case)
- detection overlaps the banner animation instead of queueing behind it
- glyph.fail/glyph.warn at 13 sites that bypassed the constant; removed unused
prompter exports, a dead ENV_PATHS re-export, and an unused export keyword
* fix(setup): make doctor understand the compose env layout
Compose writes a single root .env (what docker-compose reads via env_file) but
the checks required the three per-app files, so a successful compose install was
followed by doctor printing three failures and exiting 1 — and the whole
coherence catalog was skipped because it keyed off apps/sim/.env existing.
Layout is now derived from what's on disk and every check consults it: file and
schema checks iterate the layout's targets, consistency reports skip when
there's only one file to mirror, and coherence/live read the layout's primary
file. The wizard's existing-config detection counts root for the same reason —
a compose install used to read as unconfigured and re-run from scratch.
* feat(cli-auth): device-authorization poll flow, drop the loopback listener
The CLI no longer binds a local port. It generates a request id + poll secret,
opens /cli/auth, and polls /api/cli/auth/poll over TLS while the user approves
in the browser — so the flow works over SSH and inside containers, where the
browser and terminal don't share a machine.
- approve stores the approval keyed by request id (session-authed, userId from
the session only); poll verifies the secret before an atomic claim, so an
observer of the semi-public request id can neither mint nor cancel it
- pairing code stays as the anti-phishing compare; no key ever crosses the
browser; done page just confirms
- removes the loopback listener, /token exchange, buildCliHandoffUrl, and
validateCliCallbackUrl (+ its tests) — nothing hands a key to a URL anymore
* fix(setup): reuse an existing managed Postgres container instead of colliding
A running sim-postgres fell through to `docker run --name sim-postgres` and died
on the name conflict; a stopped one failed with "no DATABASE_URL to reach it"
because the generated password only lived in the env files a fresh clone lacks.
Both facts are recoverable from Docker: the ladder now reads the published port
and password back via `docker inspect` and reuses the container (starting it if
stopped). A container that won't answer prompts before recreating, and never
drops the data volume silently.
* improvement(setup): audience-first run-mode hints
Each run mode now names who it's for — compose for self-hosting/evaluating, dev
for contributing to Sim, k8s for rehearsing a production deploy — with the live
detection state (Docker/kube/VM) appended.
* fix(cli-auth): retry a failed mint, port container/port fixes to Redis + k8s
Review findings from #5911:
- poll now reserves the mint with an atomic NX lock instead of deleting the
approval up front, so a failed mint (e.g. mothership blip) is retried by the
next poll instead of forcing a fresh browser approval; the lock still prevents
a double-mint and its TTL frees the slot if the caller dies
- setup reuses/recreates an unhealthy managed sim-redis instead of colliding on
the name (Redis has no data volume, so it removes and recreates without a prompt)
- k8s failure-path hints carry --context, matching the success-path hints, so a
changed ambient context can't send diagnostics to the wrong cluster
- compose port-free waits for a killed port to actually release before
re-checking; SIGKILL is async, so the immediate re-check re-saw the port
* fix(setup): harden mint cleanup, Windows browser, container detection, helm cwd
Review findings from #5911:
- a post-mint completeApproval failure no longer routes into releaseMint — the
mint lock now outlives the approval (shared TTL), so a cleanup blip can't leave
a re-mintable window and orphan a key; cleanup is best-effort after the key ships
- compose doctor --fix writes the feature-flag twin to the layout's primary env
(root .env on a compose install), not always apps/sim/.env
- Windows opens the browser via `cmd /c start "" <url>` — `start` is a shell
builtin, so spawning it directly ENOENT'd and the handoff never opened
- managed-container detection filters loosely and pins the exact name in code;
Docker's `name=^x$` anchor matches the internal `/x` form and often missed,
skipping the reuse branch
- the shared helm/kind run helper pins cwd to the repo root, matching helm test,
so `helm upgrade --install ./helm/sim` works from any working directory
* feat(chat-keys): standalone manage page, drop from settings nav, refresh README
- Add /account/settings/chat-keys — a linkable page to view, create, and revoke Chat API keys
- Remove Chat keys from the settings sidebar (account + unified nav) and its render branches
- README: replace Docker Compose + Manual Setup with the bun run setup wizard; drop the manual COPILOT_API_KEY step, point to the manage page
* fix(setup): per-key reason in the secret-replacement warning
Cursor: the warn hardcoded '64-character hex key', but only ENCRYPTION_KEY/API_ENCRYPTION_KEY require that — BETTER_AUTH_SECRET/INTERNAL_API_SECRET only need length >= 32. Use the existing secretRequirement(key) helper so each replaced key reports its actual requirement.
* fix(setup): compose doctor schema, cross-platform binary detection, quoted context hints
- Doctor: for the compose (root) env layout, require only the secrets compose has no interpolation default for (BETTER_AUTH_SECRET/ENCRYPTION_KEY/INTERNAL_API_SECRET). DATABASE_URL/BETTER_AUTH_URL/NEXT_PUBLIC_APP_URL come from docker-compose ${VAR:-default}, so a healthy compose install no longer fails doctor.
- Binary detection: use Bun.which instead of which (which is absent on Windows), so kubectl/helm/kind/docker resolve cross-platform.
- k8s diagnostic hints: POSIX-quote the kube-context so a context with whitespace/metacharacters can't break or inject into a copied command.
* fix(setup): quote kube-context in the helm uninstall tear-down hint too
The tear-down hint used --kube-context ${context} raw while the sibling kubectl hints already used shq(); a context with whitespace/metacharacters could break or inject into the copied command. All copyable k8s hints now go through shq(context).
* feat(setup): sim lifecycle CLI — start/stop/status/logs/down/reset
Turn the setup entry into a 'sim' command umbrella so there's one place to run everything, not scattered docker/bun commands. Adds a global bin (bun link) + a bun run sim fallback.
- Detects how you're running (compose file / managed dev containers / helm release) from disk + docker/helm state — no persisted mode. Ambiguous installs prompt.
- start/stop/restart/logs work per mode; down removes containers (volumes kept); reset archives .env + wipes managed data; both destructive verbs confirm first.
- status shows detected mode, container states, and app/realtime health.
- Wizard outro + README now point at the sim commands and the one-time bun link.
* feat(setup): 'bun run sim' is the primary entry; bare invocation prints help
- Lead usage/wizard-outro/README with 'bun run sim <cmd>' (works with zero PATH setup); global bare 'sim' via bun link is an optional upgrade, with the ~/.bun/bin PATH caveat spelled out (Homebrew's bun omits it).
- Bare 'sim' now prints help instead of launching the wizard; the wizard is 'sim setup'. The 'setup' npm script passes the keyword so 'bun run setup' is unchanged.
* fix(setup): quote the auth URL for cmd /c start on Windows
Cursor (High): cmd re-parses the command line and treats & in the query string as a command separator, so cmd /c start opened a URL truncated at the first &, breaking the key flow on win32 (the handoff URL always has request/challenge/pairing). Quote the URL and pass args verbatim so & stays literal.
* fix(setup): verify kube-context is really local; lengthen CLI handoff wait
- k8s: a context named like a local cluster (kind-*, docker-desktop) can actually point at a remote API server. Verify the server host is loopback/docker-internal before defaulting the 'use this context?' confirm to yes; otherwise warn and default to no, so generated secrets can't ship to a remote cluster on a blind Enter.
- cli-auth: bump the device-flow wait from 3 to 15 minutes so first-time users have time to sign up, wait for the email OTP, and approve before the terminal stops polling. The server-side approval record keeps its own short TTL, so a longer client wait only costs cheap rate-limited polls.
* fix(setup): only manage k8s lifecycle on a verified-local context
Greptile: sim down/reset used the ambient kube-context, so switching context after setup could uninstall a same-named sim-dev release from the wrong cluster. Gate k8sInstall on the same locality check the wizard uses (API server is loopback/docker-internal) via a shared isLocalKubeContext helper — the wizard only ever deploys locally, so a remote current-context is never treated as a Sim install.
* fix(setup): doctor skips placeholder secrets when seeding; reset names its target
- checks: the missing-file autofix copied shared keys from apps/sim/.env whenever truthy, including .env.example placeholders — doctor --fix could seed unusable secrets into realtime/db env files. Skip placeholders, matching autofixForMissing.
- lifecycle: reset now names the exact install (k8s context / compose file / dev containers) in its confirm, so a destructive reset can't silently hit the wrong same-named install after a context switch (down already names the context).
* fix(cli-auth): size the poll rate limit to the poll cadence; honor Retry-After
The poll route used the default public-IP bucket (10 burst, 5/min) but the CLI polls every 2s (30/min), so it 429'd within ~20s — worse behind a slow dev cold-compile. Give the endpoint a bucket matched to its cadence (60 burst, 60/min); it's not a brute-force surface (unknown request id returns pending, minting needs the 256-bit verifier). Also make the CLI honor Retry-After and back off on 429 so a shared-NAT per-IP limit degrades gracefully instead of hammering.
* fix(setup): check ports before starting the dev server, not just compose
Local dev auto-start spawned bun run dev:full with no port check, so it silently started a server that couldn't bind when 3000/3002 were already taken (e.g. another worktree's dev server). Extract compose's port-conflict resolver into a shared ensurePortsFree(ports) and run it before the dev start too — kill/recheck/leave, same as compose. Leaving the ports skips the auto-start with guidance instead of failing; compose still treats it as fatal.
* fix(setup): verify the kube cluster is reachable, not just local
A kubeconfig context can outlive its cluster — a kind cluster gets deleted or its Docker container stops (Docker/machine restart), but the context entry remains, pointing at a dead API-server port. The wizard checked the context looked local and handed it to helm, which failed with 'cluster unreachable'.
Add a clusterReachable() liveness probe: only offer the current context when it actually answers; if a local context is dead, fall through to the kind path. There, if kind still knows 'sim' but it's stopped, start its node containers and wait for the API; if it's gone, create fresh. Either way the user gets a working cluster instead of a cryptic helm failure.
* fix(helm): point appVersion at published image tags (v-prefixed, current)
The chart's appVersion was "0.6.73", but CI publishes GHCR tags with a v prefix (its release-commit regex captures v0.7.45). Since sim.image defaults every image tag to Chart.AppVersion, a default helm install requested ghcr.io/simstudioai/{simstudio,realtime,migrations}:0.6.73 — a tag that has never existed — so app and realtime sat in ImagePullBackOff and helm --wait failed with 'progress deadline exceeded'. Any self-hoster installing with default values hit this, not just the setup wizard.
Set appVersion to v0.7.45 (latest release on main; all three images verified present on ghcr) and bump the chart version to 1.1.1. Verified with helm lint, helm template (all images render as v0.7.45), and a live helm upgrade on a kind cluster where the new pods pull successfully while the old 0.6.73 pods remain in ImagePullBackOff.
* Revert "fix(helm): point appVersion at published image tags (v-prefixed, current)"
This reverts commit 28b6047d1d.
* chore(api-validation): rebaseline route count to 977 after staging merge
Staging moved the baseline to 975; this branch's two CLI-auth routes (approve, poll) make 977. The clean merge absorbed the earlier +2 adjustment.
* fix(settings): don't highlight a sibling nav item on nested settings pages
/account/settings/chat-keys is a real page but deliberately not a nav item, so the sidebar's parseSettingsPathSection fell through to defaultSection ('general') and highlighted General — the page read as though it lived inside General.
Resolve the sidebar's active item with a null default so an unmatched nested route highlights nothing, and widen SettingsSidebar's activeSection to string | null. The section feeding the title/description provider keeps its default (pages override title/description anyway), and /account/settings/billing/credit-usage still correctly highlights Billing.
* fix(setup,auth): manage explicitly-confirmed k8s contexts, fail loudly on reset, clear stale post-auth redirect
- lifecycle: detection is now factual — a sim-dev release either exists on the current context or it doesn't. Gating on locality stranded a release the user explicitly confirmed during setup (status/start/stop/down/reset all claimed no k8s install). Locality is recorded instead and surfaced through describeInstall, which every destructive confirm renders, so acting on a non-local cluster is named and defaulted to no rather than silently blocked or silently allowed.
- lifecycle: reset no longer discards helm uninstall's exit status. Env files are archived by that point, so claiming 'Reset complete' while the release still runs is the worst outcome — it now throws with retry/inspect commands.
- auth: signup clears POST_AUTH_REDIRECT_STORAGE_KEY when it has no callbackUrl, and the verification-disabled path consumes it, so a stale CLI/invite destination can't leak into a later flow in the same tab.
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
import { spawnSync } from 'node:child_process'
|
|
import { DB_CONTAINER, type Detection, REDIS_CONTAINER, runDetection } from './detect.ts'
|
|
import { archiveEnvFile, ROOT } from './env-files.ts'
|
|
import { SetupError } from './errors.ts'
|
|
import { isLocalKubeContext } from './modes/k8s.ts'
|
|
import { httpHealth } from './probes.ts'
|
|
import * as p from './prompter.ts'
|
|
import { glyph, theme } from './theme.ts'
|
|
|
|
const APP_URL = 'http://localhost:3000'
|
|
const REALTIME_HEALTH = 'http://localhost:3002/health'
|
|
const POSTGRES_VOLUME = 'sim-postgres-data'
|
|
const COMPOSE_FILES = ['docker-compose.prod.yml', 'docker-compose.local.yml'] as const
|
|
const K8S_RELEASE = 'sim-dev'
|
|
const K8S_NAMESPACE = 'sim-dev'
|
|
|
|
export const LIFECYCLE_COMMANDS = [
|
|
'start',
|
|
'stop',
|
|
'restart',
|
|
'status',
|
|
'logs',
|
|
'down',
|
|
'reset',
|
|
] as const
|
|
export type LifecycleCommand = (typeof LIFECYCLE_COMMANDS)[number]
|
|
|
|
export function isLifecycleCommand(value: string): value is LifecycleCommand {
|
|
return (LIFECYCLE_COMMANDS as readonly string[]).includes(value)
|
|
}
|
|
|
|
/**
|
|
* POSIX-quote a value for a copyable shell hint — a kube-context can contain
|
|
* whitespace or metacharacters that would break a copied command.
|
|
*/
|
|
function shq(value: string): string {
|
|
if (/^[A-Za-z0-9._/-]+$/.test(value)) return value
|
|
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
}
|
|
|
|
/** Non-throwing docker probe; returns trimmed stdout or null on any failure. */
|
|
function dockerText(args: string[]): string | null {
|
|
const result = spawnSync('docker', args, { cwd: ROOT, encoding: 'utf8' })
|
|
return result.status === 0 ? result.stdout.trim() : null
|
|
}
|
|
|
|
/** Docker command whose output the user should see (up, logs); returns exit code. */
|
|
function dockerInherit(args: string[]): number {
|
|
return spawnSync('docker', args, { cwd: ROOT, stdio: 'inherit' }).status ?? 1
|
|
}
|
|
|
|
/** Docker command that must succeed; throws a SetupError with stderr on failure. */
|
|
function dockerRun(args: string[], failMessage: string): void {
|
|
const result = spawnSync('docker', args, { cwd: ROOT, encoding: 'utf8' })
|
|
if (result.status !== 0) {
|
|
throw new SetupError(`${failMessage}: ${result.stderr.trim() || result.stdout.trim()}`)
|
|
}
|
|
}
|
|
|
|
interface ComposeInstall {
|
|
kind: 'compose'
|
|
file: string
|
|
}
|
|
interface DevInstall {
|
|
kind: 'dev'
|
|
postgres: boolean
|
|
redis: boolean
|
|
}
|
|
interface K8sInstall {
|
|
kind: 'k8s'
|
|
context: string
|
|
/** False when the context's API server is outside the local allowlist — flagged before destructive ops. */
|
|
local: boolean
|
|
}
|
|
type Install = ComposeInstall | DevInstall | K8sInstall
|
|
|
|
/**
|
|
* A compose project brought up from ROOT reuses the same project name at `ps`,
|
|
* so probing each candidate compose file recovers exactly which one owns
|
|
* containers — no need to guess the project name or persist the choice.
|
|
*/
|
|
function composeInstalls(): ComposeInstall[] {
|
|
return COMPOSE_FILES.filter((file) => dockerText(['compose', '-f', file, 'ps', '-aq'])).map(
|
|
(file) => ({ kind: 'compose', file })
|
|
)
|
|
}
|
|
|
|
/** Dev mode owns the split env files and, usually, the managed Postgres/Redis. */
|
|
function devInstall(detection: Detection): DevInstall | null {
|
|
const postgres = detection.dbContainer?.managed ?? false
|
|
const redis = detection.redisContainer?.managed ?? false
|
|
const splitEnv = detection.envFiles.sim || detection.envFiles.realtime || detection.envFiles.db
|
|
if (!postgres && !redis && !splitEnv) return null
|
|
return { kind: 'dev', postgres, redis }
|
|
}
|
|
|
|
/**
|
|
* Detection is factual: a release either exists on the current context or it
|
|
* doesn't. Setup lets the user explicitly confirm a context whose API server is
|
|
* outside the local allowlist, so gating detection on locality would strand that
|
|
* release — `status`/`start`/`stop`/`down`/`reset` would all claim there is no
|
|
* Kubernetes install. Instead the locality is recorded and surfaced: every
|
|
* destructive path names the target (and flags a non-local one) before acting.
|
|
*/
|
|
function k8sInstall(detection: Detection): K8sInstall | null {
|
|
const context = detection.kubeContext
|
|
if (!context) return null
|
|
const status = spawnSync(
|
|
'helm',
|
|
['status', K8S_RELEASE, '--kube-context', context, '-n', K8S_NAMESPACE],
|
|
{ stdio: 'ignore' }
|
|
)
|
|
if (status.status !== 0) return null
|
|
return { kind: 'k8s', context, local: isLocalKubeContext(context) }
|
|
}
|
|
|
|
function detectInstalls(detection: Detection): Install[] {
|
|
const installs: Install[] = [...composeInstalls()]
|
|
const dev = devInstall(detection)
|
|
if (dev) installs.push(dev)
|
|
const k8s = k8sInstall(detection)
|
|
if (k8s) installs.push(k8s)
|
|
return installs
|
|
}
|
|
|
|
function describeInstall(install: Install): string {
|
|
if (install.kind === 'compose') return `Docker Compose (${install.file})`
|
|
if (install.kind === 'dev') return 'Local dev (managed Postgres/Redis)'
|
|
// Naming a non-local cluster is the guard against acting on the wrong one after
|
|
// an ambient context switch — every destructive confirm renders this string.
|
|
const scope = install.local ? '' : ' — NOT a verified-local cluster'
|
|
return `Kubernetes (context ${install.context}${scope})`
|
|
}
|
|
|
|
/** One install → use it; several → let the user pick; none → null. */
|
|
async function resolveInstall(installs: Install[]): Promise<Install | null> {
|
|
if (installs.length <= 1) return installs[0] ?? null
|
|
const choice = await p.select({
|
|
message: 'Multiple installs detected — which one?',
|
|
options: installs.map((install, index) => ({
|
|
value: String(index),
|
|
label: describeInstall(install),
|
|
})),
|
|
initialValue: '0',
|
|
})
|
|
return installs[Number(choice)]
|
|
}
|
|
|
|
function managedNames(install: DevInstall): string[] {
|
|
const names: string[] = []
|
|
if (install.postgres) names.push(DB_CONTAINER)
|
|
if (install.redis) names.push(REDIS_CONTAINER)
|
|
return names
|
|
}
|
|
|
|
function k8sReachHints(context: string): string {
|
|
const c = shq(context)
|
|
return [
|
|
`kubectl --context ${c} -n ${K8S_NAMESPACE} port-forward svc/${K8S_RELEASE}-app 3000:3000`,
|
|
`kubectl --context ${c} -n ${K8S_NAMESPACE} get pods`,
|
|
].join('\n')
|
|
}
|
|
|
|
function start(install: Install): void {
|
|
if (install.kind === 'compose') {
|
|
const spin = p.spinner()
|
|
spin.start('Starting containers…')
|
|
dockerRun(['compose', '-f', install.file, 'up', '-d'], 'docker compose up failed')
|
|
spin.stop('Containers up')
|
|
p.note(
|
|
[`open ${APP_URL}`, 'follow logs: sim logs', 'stop: sim stop'].join('\n'),
|
|
'Running'
|
|
)
|
|
return
|
|
}
|
|
if (install.kind === 'dev') {
|
|
const names = managedNames(install)
|
|
for (const name of names) dockerRun(['start', name], `docker start ${name} failed`)
|
|
if (names.length) p.log.step(`Started ${names.join(', ')}`)
|
|
p.note(
|
|
['start the dev server: bun run dev:full', 'stop DB/Redis: sim stop'].join('\n'),
|
|
'Ready'
|
|
)
|
|
return
|
|
}
|
|
p.note(k8sReachHints(install.context), 'Kubernetes is managed with kubectl')
|
|
}
|
|
|
|
function stop(install: Install): void {
|
|
if (install.kind === 'compose') {
|
|
const spin = p.spinner()
|
|
spin.start('Stopping containers…')
|
|
dockerRun(['compose', '-f', install.file, 'stop'], 'docker compose stop failed')
|
|
spin.stop('Containers stopped (data kept)')
|
|
p.note(['start again: sim start', 'remove: sim down'].join('\n'), 'Stopped')
|
|
return
|
|
}
|
|
if (install.kind === 'dev') {
|
|
const names = managedNames(install)
|
|
for (const name of names) dockerRun(['stop', name], `docker stop ${name} failed`)
|
|
if (names.length) p.log.step(`Stopped ${names.join(', ')}`)
|
|
p.note(
|
|
'The dev server runs in the foreground — stop it with Ctrl-C in its terminal.',
|
|
'Dev server'
|
|
)
|
|
return
|
|
}
|
|
const c = shq(install.context)
|
|
p.note(
|
|
[
|
|
`scale down: kubectl --context ${c} -n ${K8S_NAMESPACE} scale deploy --all --replicas=0`,
|
|
`scale up: kubectl --context ${c} -n ${K8S_NAMESPACE} scale deploy --all --replicas=1`,
|
|
'tear down: sim down',
|
|
].join('\n'),
|
|
'Kubernetes'
|
|
)
|
|
}
|
|
|
|
function restart(install: Install): void {
|
|
if (install.kind === 'compose') {
|
|
const spin = p.spinner()
|
|
spin.start('Restarting containers…')
|
|
dockerRun(['compose', '-f', install.file, 'restart'], 'docker compose restart failed')
|
|
spin.stop('Containers restarted')
|
|
p.note(`open ${APP_URL}`, 'Running')
|
|
return
|
|
}
|
|
if (install.kind === 'dev') {
|
|
const names = managedNames(install)
|
|
for (const name of names) dockerRun(['restart', name], `docker restart ${name} failed`)
|
|
if (names.length) p.log.step(`Restarted ${names.join(', ')}`)
|
|
p.note('Restart the dev server manually (Ctrl-C, then bun run dev:full).', 'Dev server')
|
|
return
|
|
}
|
|
p.note(k8sReachHints(install.context), 'Kubernetes is managed with kubectl')
|
|
}
|
|
|
|
function showLogs(install: Install): void {
|
|
if (install.kind === 'compose') {
|
|
dockerInherit(['compose', '-f', install.file, 'logs', '-f', '--tail', '100'])
|
|
return
|
|
}
|
|
if (install.kind === 'dev') {
|
|
const names = managedNames(install)
|
|
p.note(
|
|
[
|
|
'the dev server logs stream in its own terminal (bun run dev:full)',
|
|
...names.map((name) => `container: docker logs -f ${name}`),
|
|
].join('\n'),
|
|
'Logs'
|
|
)
|
|
return
|
|
}
|
|
spawnSync(
|
|
'kubectl',
|
|
['--context', install.context, '-n', K8S_NAMESPACE, 'logs', '-f', `deploy/${K8S_RELEASE}-app`],
|
|
{ stdio: 'inherit' }
|
|
)
|
|
}
|
|
|
|
async function down(install: Install): Promise<void> {
|
|
const ok = await p.confirm({
|
|
message: `Remove ${describeInstall(install)} containers? Data volumes are kept.`,
|
|
initialValue: false,
|
|
})
|
|
if (!ok) {
|
|
p.log.info('Left it running.')
|
|
return
|
|
}
|
|
if (install.kind === 'compose') {
|
|
dockerRun(['compose', '-f', install.file, 'down'], 'docker compose down failed')
|
|
p.log.step('Containers removed (volumes kept)')
|
|
return
|
|
}
|
|
if (install.kind === 'dev') {
|
|
const names = managedNames(install)
|
|
if (names.length) {
|
|
dockerRun(['rm', '-f', ...names], 'docker rm failed')
|
|
p.log.step(`Removed ${names.join(', ')} (Postgres volume ${POSTGRES_VOLUME} kept)`)
|
|
} else {
|
|
p.log.info('No managed containers to remove.')
|
|
}
|
|
return
|
|
}
|
|
const result = spawnSync(
|
|
'helm',
|
|
['uninstall', K8S_RELEASE, '--kube-context', install.context, '-n', K8S_NAMESPACE],
|
|
{ stdio: 'inherit' }
|
|
)
|
|
if (result.status !== 0) throw new SetupError('helm uninstall failed')
|
|
p.log.step(`Uninstalled ${K8S_RELEASE}`)
|
|
}
|
|
|
|
async function reset(install: Install | null): Promise<void> {
|
|
// Name the exact target: k8s acts on the ambient context, so spelling out which
|
|
// cluster (or compose file / dev containers) is about to be wiped keeps a reset
|
|
// from silently hitting the wrong same-named install after a context switch.
|
|
const target = install ? ` ${describeInstall(install)} will be removed.` : ''
|
|
const ok = await p.confirm({
|
|
message: theme.error(
|
|
`Reset archives your .env files and wipes managed data (volumes).${target} Continue?`
|
|
),
|
|
initialValue: false,
|
|
})
|
|
if (!ok) {
|
|
p.log.info('Reset cancelled.')
|
|
return
|
|
}
|
|
for (const target of ['sim', 'realtime', 'db', 'root'] as const) {
|
|
const backup = archiveEnvFile(target)
|
|
if (backup) p.log.step(`Archived ${backup}`)
|
|
}
|
|
if (install?.kind === 'compose') {
|
|
dockerRun(['compose', '-f', install.file, 'down', '-v'], 'docker compose down -v failed')
|
|
p.log.step('Containers and volumes removed')
|
|
} else if (install?.kind === 'dev') {
|
|
const names = managedNames(install)
|
|
if (names.length) spawnSync('docker', ['rm', '-f', ...names], { cwd: ROOT, stdio: 'ignore' })
|
|
spawnSync('docker', ['volume', 'rm', POSTGRES_VOLUME], { cwd: ROOT, stdio: 'ignore' })
|
|
p.log.step('Managed containers and Postgres volume removed')
|
|
} else if (install?.kind === 'k8s') {
|
|
const uninstall = spawnSync(
|
|
'helm',
|
|
['uninstall', K8S_RELEASE, '--kube-context', install.context, '-n', K8S_NAMESPACE],
|
|
{ stdio: 'inherit' }
|
|
)
|
|
// Env files are already archived, so a failed uninstall leaves a live release
|
|
// with no local config — the worst thing to do is call that a success.
|
|
if (uninstall.status !== 0) {
|
|
throw new SetupError(
|
|
`env files were archived, but helm uninstall failed — the ${K8S_RELEASE} release is still running.`,
|
|
[
|
|
`retry: ${theme.command(`helm uninstall ${K8S_RELEASE} --kube-context ${shq(install.context)} -n ${K8S_NAMESPACE}`)}`,
|
|
`check the release: ${theme.command(`helm status ${K8S_RELEASE} --kube-context ${shq(install.context)} -n ${K8S_NAMESPACE}`)}`,
|
|
]
|
|
)
|
|
}
|
|
p.log.step(`Uninstalled ${K8S_RELEASE}`)
|
|
}
|
|
p.note(`start fresh with ${theme.command('sim setup')}`, 'Reset complete')
|
|
}
|
|
|
|
async function status(): Promise<void> {
|
|
const detection = await runDetection()
|
|
const installs = detectInstalls(detection)
|
|
console.log(`\n${theme.heading('◆ Sim status')}\n`)
|
|
if (installs.length === 0) {
|
|
console.log(` ${glyph.warn} No Sim install detected — run ${theme.command('sim setup')}.`)
|
|
return
|
|
}
|
|
for (const install of installs) console.log(` ${glyph.pass} ${describeInstall(install)}`)
|
|
const containerState = (state: { state: 'running' | 'stopped' } | null) =>
|
|
state ? state.state : 'absent'
|
|
console.log()
|
|
console.log(` postgres (${DB_CONTAINER}): ${containerState(detection.dbContainer)}`)
|
|
console.log(` redis (${REDIS_CONTAINER}): ${containerState(detection.redisContainer)}`)
|
|
const [app, realtime] = await Promise.all([
|
|
httpHealth(`${APP_URL}/api/health`),
|
|
httpHealth(REALTIME_HEALTH),
|
|
])
|
|
console.log()
|
|
console.log(` app (:3000) ${app ? glyph.pass : glyph.fail}`)
|
|
console.log(` realtime (:3002) ${realtime ? glyph.pass : glyph.fail}`)
|
|
}
|
|
|
|
export async function runLifecycle(command: LifecycleCommand): Promise<void> {
|
|
if (command === 'status') return status()
|
|
|
|
const installs = detectInstalls(await runDetection())
|
|
|
|
// Reset stays useful with nothing running — it still archives stray .env files.
|
|
if (command === 'reset') return reset(await resolveInstall(installs))
|
|
|
|
const install = await resolveInstall(installs)
|
|
if (!install) {
|
|
p.log.warn(`No Sim install detected. Run ${theme.command('sim setup')} first.`)
|
|
return
|
|
}
|
|
switch (command) {
|
|
case 'start':
|
|
return start(install)
|
|
case 'stop':
|
|
return stop(install)
|
|
case 'restart':
|
|
return restart(install)
|
|
case 'logs':
|
|
return showLogs(install)
|
|
case 'down':
|
|
return down(install)
|
|
}
|
|
}
|