feat(setup): publish standalone self-hosting package (#6849)

* feat(setup): publish standalone self-hosting package

* fix(setup): refresh discovered compose installs

* improvement(setup): unify repository command

* fix(setup): harden standalone package launch

* Update README.md

* fix(setup): isolate standalone compose installs

* fix(setup): restore default stopped installs
This commit is contained in:
Theodore Li
2026-08-19 20:04:05 -04:00
committed by GitHub
parent 04e0fe00c0
commit 1372977d07
108 changed files with 4384 additions and 2417 deletions
+20 -14
View File
@@ -172,8 +172,8 @@ Optional companions: `credentialLabels` (override the picker's section/connect-r
### OAuth deployment availability (required for integration blocks)
A visible tools-category block with OAuth is deployment-gated. Its `oauth-input.serviceId` is
projected into `apps/sim/lib/integrations/integrations.json`, then resolved through
`resolveOAuthClientCapabilityId()` in `apps/sim/lib/core/config/env-capabilities.ts`.
projected into `packages/deployment-config/src/integrations.json`, then resolved through
`resolveOAuthClientCapabilityId()` in `packages/deployment-config/src/env-capabilities.ts`.
When adding or changing an OAuth integration block:
@@ -184,13 +184,14 @@ When adding or changing an OAuth integration block:
3. For a new capability, add its required client fields to `OAUTH_CLIENT_CAPABILITIES` and ensure
every referenced field exists in the env schema in `apps/sim/lib/core/config/env.ts`. Then add
the matching `text` or `secret` input modes to `OAUTH_CLIENT_SETUP_FIELDS` in
`scripts/setup/capability-config.ts`. The CLI catalog is exhaustively typed and checked against
the runtime field list; do not infer secrecy from the field name.
4. If the canonical OAuth service declares `serviceAccountProviderId`, keep
`SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` in
`apps/sim/lib/integrations/service-account-metadata.ts` aligned. Set
`deploymentRequirement` only when the service-account path is preview-gated or depends on the
OAuth client fields; otherwise omit it.
`packages/sim-setup/src/capability-config.ts`. The CLI catalog is exhaustively typed and checked
against the runtime field list; do not infer secrecy from the field name.
4. If the canonical OAuth service declares `serviceAccountProviderId`, run
`bun run deployment-config:generate`; this regenerates the provider-ID facts in
`packages/deployment-config/src/service-account-providers.generated.ts`. Never hand-edit that
generated map. Add `deploymentRequirement` policy in
`packages/deployment-config/src/service-account-metadata.ts` only when the service-account path
is preview-gated or depends on the OAuth client fields; otherwise omit it.
Missing capability metadata is a runtime configuration error, not a reason to make the integration
silently available.
@@ -992,16 +993,21 @@ After adding or changing one, run:
```bash
bun run scripts/generate-docs.ts
bun run deployment-config:generate
bun run integration-catalog:check
bun run deployment-config:check
bun run docs:check
```
The catalog check independently derives deployment metadata from the executable block registry and
compares it with the committed `apps/sim/lib/integrations/integrations.json`. `docs:check` re-renders
every generated docs artifact in memory and fails on any committed file that differs — it runs in CI
via `check:audits`, so commit the full generator output. If the generator also trues up pages an
earlier PR left stale, commit that catch-up too; reverting it as "unrelated drift" makes `docs:check`
fail.
compares it with the committed `packages/deployment-config/src/integrations.json`. The deployment
config check verifies the generated service-account facts against the canonical OAuth registry and
catalog. `docs:check` re-renders every generated docs artifact in memory and fails on any committed
file that differs — it runs in CI via `check:audits`, so commit the full generator output. If the
generator also trues up pages an earlier PR left stale, commit that catch-up too; reverting it as
"unrelated drift" makes `docs:check` fail. Review the generated diff and keep only intentional
changes.
## Checklist Before Finishing
- [ ] `integrationType` is set to the correct `IntegrationType` enum value
+19 -12
View File
@@ -538,16 +538,18 @@ the OAuth service configuration, deployment availability, and the setup CLI.
1. Ensure the block has exactly one distinct OAuth `serviceId` and that it matches the canonical
service entry in `apps/sim/lib/oauth/oauth.ts`.
2. Confirm `resolveOAuthClientCapabilityId(serviceId)` resolves to the intended provider entry in
`OAUTH_CLIENT_CAPABILITIES` in `apps/sim/lib/core/config/env-capabilities.ts`. Google and
`OAUTH_CLIENT_CAPABILITIES` in `packages/deployment-config/src/env-capabilities.ts`. Google and
Microsoft service IDs deliberately share provider-level capabilities.
3. For a new OAuth provider, add the required client fields to `OAUTH_CLIENT_CAPABILITIES`, add
every referenced field to the env schema in `apps/sim/lib/core/config/env.ts`, and add the
matching `text` or `secret` entries to `OAUTH_CLIENT_SETUP_FIELDS` in
`scripts/setup/capability-config.ts`. Do not create integration-specific setup logic or infer
secret fields from naming; the CLI mapping is exhaustively checked against the runtime fields.
4. If the canonical OAuth service has `serviceAccountProviderId`, add the matching projection to
`SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` in
`apps/sim/lib/integrations/service-account-metadata.ts`. Use:
`packages/sim-setup/src/capability-config.ts`. Do not create integration-specific setup logic or
infer secret fields from naming; the CLI mapping is exhaustively checked against the runtime
fields.
4. If the canonical OAuth service has `serviceAccountProviderId`, run
`bun run deployment-config:generate` to refresh
`packages/deployment-config/src/service-account-providers.generated.ts`; never hand-edit the
generated provider-ID map. In `packages/deployment-config/src/service-account-metadata.ts`, use:
- no `deploymentRequirement` when the service-account path works independently of OAuth client fields;
- `'oauth-client'` when it requires the same deployment OAuth client fields;
- `'preview-gated'` when availability is controlled by the service-account preview block.
@@ -560,15 +562,18 @@ a resolvable capability must fail validation.
Run the documentation generator:
```bash
bun run scripts/generate-docs.ts
bun run deployment-config:generate
bun run integration-catalog:check
bun run deployment-config:check
bun run docs:check
```
This creates `apps/docs/content/docs/en/integrations/{service}.mdx` — one page per service carrying the block's Actions and, if it has one, its Triggers section. Never hand-edit generated pages; the only editable region is the `{/* MANUAL-CONTENT */}` block (see `scripts/README.md`).
The same generator refreshes `apps/sim/lib/integrations/integrations.json`. The catalog check then
derives the deployment-relevant fields from the executable block registry and compares them with the
committed projection. Review the generated diff and keep only intentional changes.
The docs generator refreshes `packages/deployment-config/src/integrations.json`, and the deployment
config generator projects service-account provider IDs from that catalog plus the canonical OAuth
registry. The checks compare both committed projections with their sources. Review the generated
diff and keep only intentional changes.
## V2 Integration Pattern
@@ -647,14 +652,16 @@ If creating V2 versions (API-aligned outputs):
- [ ] Created `index.ts` barrel export
- [ ] Registered all triggers in `triggers/registry.ts`
### Docs
### Docs and deployment metadata
- [ ] Ran `bun run scripts/generate-docs.ts`
- [ ] Ran `bun run deployment-config:generate` for OAuth or service-account changes
- [ ] Verified docs file created
- [ ] Reviewed and committed the generated `apps/sim/lib/integrations/integrations.json` change
- [ ] Reviewed and committed the generated `packages/deployment-config/src/integrations.json` change
- [ ] `bun run integration-catalog:check` passes
- [ ] `bun run docs:check` passes — CI fails on stale generated docs, so commit the full generator
output, including catch-up regeneration for pages another PR left stale (never revert it as
"unrelated drift")
- [ ] `bun run deployment-config:check` passes
### Final Validation (Required)
- [ ] Read every tool file and cross-referenced inputs/outputs against the API docs
@@ -1002,4 +1009,4 @@ requiredScopes: getScopesForService('{service}'),
11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts
12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
13. **OAuth service IDs need deployment capabilities** - Every visible OAuth integration must resolve through `OAUTH_CLIENT_CAPABILITIES`; shared Google/Microsoft aliases map to their provider capability
14. **Keep runtime and presentation separate** - Runtime OAuth fields live in `env-capabilities.ts`; CLI input modes live in the exhaustively checked `scripts/setup/capability-config.ts` mapping
14. **Keep runtime and presentation separate** - Runtime OAuth fields live in `packages/deployment-config/src/env-capabilities.ts`; CLI input modes live in the exhaustively checked `packages/sim-setup/src/capability-config.ts` mapping
+19 -14
View File
@@ -29,12 +29,13 @@ apps/sim/blocks/registry-maps.ts # Block + meta registry entry (BLOCK_REGISTR
apps/sim/components/icons.tsx # Icon definition
apps/sim/lib/auth/auth.ts # OAuth config — should use getCanonicalScopesForProvider()
apps/sim/lib/oauth/oauth.ts # OAuth provider config — single source of truth for scopes
apps/sim/lib/oauth/utils.ts # Scope utilities, SCOPE_DESCRIPTIONS for modal UI
apps/sim/lib/core/config/env-capabilities.ts # OAuth client runtime capability source of truth
apps/sim/lib/oauth/utils.ts # Scope utilities, SCOPE_DESCRIPTIONS for modal UI
packages/deployment-config/src/env-capabilities.ts # OAuth client runtime capability source of truth
apps/sim/lib/core/config/env.ts # Runtime env schema for capability fields
scripts/setup/capability-config.ts # Exhaustive CLI input-mode mapping for OAuth fields
apps/sim/lib/integrations/integrations.json # Generated client-safe integration catalog
apps/sim/lib/integrations/service-account-metadata.ts # Lightweight service-account projection
packages/sim-setup/src/capability-config.ts # Exhaustive CLI input-mode mapping for OAuth fields
packages/deployment-config/src/integrations.json # Generated client-safe integration catalog
packages/deployment-config/src/service-account-providers.generated.ts # Generated provider-ID facts
packages/deployment-config/src/service-account-metadata.ts # Handwritten deployment policy
```
## Step 2: Pull API Documentation
@@ -291,7 +292,7 @@ Scopes are centralized — the single source of truth is `OAUTH_PROVIDERS` in `l
## Step 6: Validate Deployment Availability (if OAuth service)
The deployment UI and setup CLI do not infer OAuth client fields from scopes. They resolve the
block's generated `oauthServiceId` through the application-owned capability catalog.
block's generated `oauthServiceId` through the shared deployment capability catalog.
- [ ] The visible integration block has exactly one distinct `oauth-input.serviceId`
- [ ] `resolveOAuthClientCapabilityId(serviceId)` returns the intended provider capability
@@ -299,9 +300,9 @@ block's generated `oauthServiceId` through the application-owned capability cata
- [ ] Every field listed by that capability exists in `apps/sim/lib/core/config/env.ts`
- [ ] Every capability field has the correct `text` or `secret` entry in `OAUTH_CLIENT_SETUP_FIELDS`; no CLI naming heuristic is required
- [ ] Shared Google/Microsoft service IDs resolve to their provider capability rather than duplicate entries
- [ ] `bun run setup integration <capabilityId>` is the command emitted by availability; the CLI has only the exhaustive input-mode projection, not a second runtime provider definition
- [ ] `npx @sim/setup add integration <capabilityId>` is the command emitted by availability; the CLI has only the exhaustive input-mode projection, not a second runtime provider definition
- [ ] If the canonical OAuth service declares `serviceAccountProviderId`,
`SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID[serviceId]` has the same provider ID
the generated `SERVICE_ACCOUNT_PROVIDER_BY_OAUTH_SERVICE_ID[serviceId]` has the same provider ID
- [ ] The service-account `deploymentRequirement` matches how that credential actually works:
omitted for an independent path, `'oauth-client'` when it needs the OAuth client fields, or
`'preview-gated'` when controlled by a preview block
@@ -386,13 +387,16 @@ Several files are generated from tool and block definitions. Editing a tool or b
```bash
bun run tool-metadata:generate # repo root — apps/sim/tools/generated/*
bun run scripts/generate-docs.ts # docs .mdx + lib/integrations/integrations.json + docs icons
bun run scripts/generate-docs.ts # docs .mdx + deployment-config/integrations.json + docs icons
bun run deployment-config:generate # canonical OAuth registry + catalog → provider-ID facts
bun run integration-catalog:check # registry ↔ committed deployment metadata drift
bun run docs:check # committed docs ↔ what the generator renders today
bun run deployment-config:check # OAuth registry/catalog ↔ provider-ID fact drift
```
- **`tool-metadata:generate`** — required whenever a tool's `outputs`, `params`, or descriptions change. CI enforces this with `bun run tool-metadata:check`, which fails with *"Generated tool metadata is stale"*. This is the easiest gate to miss, because nothing in the tool file hints that a generated artifact mirrors it.
- **`generate-docs`** — required whenever block metadata changes (`bgColor`, `name`, `description`, operations, outputs). Regenerates the integration `.mdx`, `integrations.json`, and the docs copy of `components/icons.tsx`.
- **`generate-docs`** — required whenever block metadata changes (`bgColor`, `name`, `description`, operations, outputs). Regenerates the integration `.mdx`, `packages/deployment-config/src/integrations.json`, and the docs copy of `components/icons.tsx`.
- **`deployment-config:generate`** — required for OAuth or service-account changes. Regenerates provider-ID facts from the canonical OAuth registry and integration catalog; special deployment requirements remain handwritten policy.
- **`integration-catalog:check`** — loads the executable block registry, derives visible integration
deployment fields, and compares them with the committed catalog. It catches missing/unexpected
entries and stale auth/service IDs without loading the executable registry in client code.
@@ -419,9 +423,10 @@ After fixing, confirm:
4. Derived artifacts regenerated and their diffs reviewed (see above)
5. `bun run integration-catalog:check` passes
6. `bun run docs:check` passes
7. For OAuth or service-account changes, `bun test apps/sim/lib/integrations/availability.server.test.ts` passes
8. Re-read all modified files to verify fixes are correct
9. Any remaining unknown response schemas were explicitly reported to the user instead of guessed
7. For OAuth or service-account changes, `bun run deployment-config:check` passes
8. For OAuth or service-account changes, `bun run --cwd apps/sim test lib/integrations/availability.server.test.ts` passes
9. Re-read all modified files to verify fixes are correct
10. Any remaining unknown response schemas were explicitly reported to the user instead of guessed
## Checklist Summary
@@ -437,7 +442,7 @@ After fixing, confirm:
- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes
- [ ] Validated OAuth `serviceId` resolves to the intended `OAUTH_CLIENT_CAPABILITIES` entry and all capability fields exist in the env schema
- [ ] Validated service-account projection and deployment requirement against the canonical OAuth service config
- [ ] Regenerated `integrations.json` when block metadata changed and ran `bun run integration-catalog:check`
- [ ] Regenerated deployment config when block/OAuth metadata changed and ran both catalog checks
- [ ] Validated pagination consistency across tools and block
- [ ] Validated memory load safety using `.agents/skills/memory-load-check/SKILL.md` when tools list/search/download/import/export/batch data
- [ ] Validated error handling (error checks, meaningful messages)
+174
View File
@@ -0,0 +1,174 @@
name: Publish Sim Setup Package
on:
push:
branches: [main, staging, dev]
paths:
- 'packages/sim-setup/**'
- 'packages/deployment-config/**'
- 'packages/security/**'
- 'packages/utils/**'
- 'docker-compose.prod.yml'
- 'bun.lock'
permissions:
contents: read
concurrency:
group: publish-sim-setup-${{ github.ref }}
cancel-in-progress: false
jobs:
publish-npm:
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.14
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: '20'
- name: Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
~/.bun/install/cache
node_modules
**/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Verify npm authentication
env:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
run: bun pm whoami
- name: Check generated deployment config
run: bun run deployment-config:check
- name: Run tests
working-directory: packages/sim-setup
run: bun run test
- name: Type-check packages
run: |
bun run --cwd packages/deployment-config type-check
bun run --cwd packages/sim-setup type-check
- name: Build package
working-directory: packages/sim-setup
run: bun run build
- name: Resolve release channel
id: release
working-directory: packages/sim-setup
env:
BRANCH: ${{ github.ref_name }}
run: |
BASE_VERSION="$(bun -p "require('./package.json').version")"
if [[ ! "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Package version must be a stable X.Y.Z base, got '$BASE_VERSION'." >&2
exit 1
fi
case "$BRANCH" in
dev)
VERSION="${BASE_VERSION}-dev.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
TAG="dev"
;;
staging)
VERSION="${BASE_VERSION}-preview.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}"
TAG="staging"
;;
main)
VERSION="$BASE_VERSION"
TAG="latest"
;;
*)
echo "Unsupported release branch '$BRANCH'." >&2
exit 1
;;
esac
bun pm pkg set "version=$VERSION"
RESOLVED_VERSION="$(bun -p "require('./package.json').version")"
if [ "$RESOLVED_VERSION" != "$VERSION" ]; then
echo "Version injection mismatch: wanted '$VERSION', got '$RESOLVED_VERSION'." >&2
exit 1
fi
{
echo "version=$VERSION"
echo "tag=$TAG"
} >> "$GITHUB_OUTPUT"
- name: Smoke-test packed Node bundle
working-directory: packages/sim-setup
run: |
set -euo pipefail
SMOKE_DIR="$(mktemp -d "$RUNNER_TEMP/sim-setup-smoke.XXXXXX")"
PACKAGE_PATH="$SMOKE_DIR/sim-setup.tgz"
bun pm pack --ignore-scripts --filename "$PACKAGE_PATH" --quiet
tar -xzf "$PACKAGE_PATH" -C "$SMOKE_DIR"
if tar -tzf "$PACKAGE_PATH" | grep -Eq '^package/(apps|docker|helm|packages|scripts)/'; then
echo 'Packed setup package contains repository source directories.' >&2
exit 1
fi
test -x "$SMOKE_DIR/package/dist/index.js"
test -f "$SMOKE_DIR/package/dist/docker-compose.prod.yml"
if grep -Eq '^[[:space:]]+(build|context):' "$SMOKE_DIR/package/dist/docker-compose.prod.yml"; then
echo 'Production Compose asset contains a local build dependency.' >&2
exit 1
fi
cd "$SMOKE_DIR"
node package/dist/index.js --version
node package/dist/index.js --help
for command in config add doctor start stop restart update status logs down reset; do
node package/dist/index.js "$command" --help > /dev/null
done
node package/dist/index.js add integration --help > /dev/null
if node package/dist/index.js --quik > /dev/null 2>&1; then
echo 'Packed setup CLI accepted an unknown option.' >&2
exit 1
fi
if node package/dist/index.js start extra > /dev/null 2>&1; then
echo 'Packed setup CLI accepted an extra lifecycle operand.' >&2
exit 1
fi
- name: Verify version is unpublished
working-directory: packages/sim-setup
env:
VERSION: ${{ steps.release.outputs.version }}
run: |
if bun pm view "@sim/setup@$VERSION" version > /dev/null 2>&1; then
echo "@sim/setup@$VERSION is already published. Bump packages/sim-setup/package.json before releasing another build." >&2
exit 1
fi
- name: Publish to npm
working-directory: packages/sim-setup
env:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TAG: ${{ steps.release.outputs.tag }}
run: bun publish --access public --tag "$NPM_TAG" --no-save
- name: Summarize release
env:
VERSION: ${{ steps.release.outputs.version }}
NPM_TAG: ${{ steps.release.outputs.tag }}
run: echo "Published @sim/setup@$VERSION with the '$NPM_TAG' tag."
+26 -32
View File
@@ -27,9 +27,7 @@
### Self-hosted
```bash
git clone https://github.com/simstudioai/sim.git && cd sim
bun install
bun run setup
npx @sim/setup
```
Open [http://localhost:3000](http://localhost:3000)
@@ -72,56 +70,52 @@ Open [http://localhost:3000](http://localhost:3000)
## Self-hosting
**Requirements:** [Bun](https://bun.sh/) and [Docker](https://www.docker.com/).
**Requirements:** [Node.js 20+](https://nodejs.org/) and [Docker](https://www.docker.com/).
`bun run setup` is an interactive wizard: it provisions the database, generates secrets, writes your `.env` files, connects a Chat API key, and starts Sim the way you choose:
- **Local dev** — run from source to contribute or hack on Sim
- **Docker Compose** — a self-contained instance for testing self-hosting
- **Kubernetes (Helm)** — deploy to a local cluster
`npx @sim/setup` is an interactive wizard that creates a small `sim/` deployment directory, provisions the database, generates secrets, writes `.env`, connects a Chat API key, and starts the published Sim images with Docker Compose. It does not clone the repository.
When it finishes, open [http://localhost:3000](http://localhost:3000).
Inside a cloned Sim repository, run `bun run sim-setup` to unlock the source-only local development and Kubernetes modes.
Reconfigure an optional capability without rerunning the full wizard:
```bash
bun run setup status
bun run setup email
bun run setup storage
bun run setup sandbox
bun run setup jobs
bun run setup cache
bun run setup knowledge
bun run setup llm
bun run setup integration slack
npx @sim/setup config
npx @sim/setup add email
npx @sim/setup add storage
npx @sim/setup add sandbox
npx @sim/setup add jobs
npx @sim/setup add cache
npx @sim/setup add knowledge
npx @sim/setup add llm
npx @sim/setup add integration slack
```
`bun run setup status` detects the effective local-dev, Docker Compose, or current-context
`npx @sim/setup config` detects the effective local-dev, Docker Compose, or current-context
Helm configuration and reports configured, missing, or invalid capabilities and OAuth
integrations without printing credential values. This is separate from `bun run sim status`,
integrations without printing credential values. This is separate from `npx @sim/setup status`,
which reports whether installed services are running and healthy.
Manage your install with `bun run sim`:
Manage your install from its directory:
```bash
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 doctor # diagnose configuration problems
bun run sim down # remove containers (data kept)
bun run sim reset # archive .env and wipe managed data
npx @sim/setup start | stop | restart # bring your install up / down / cycle
npx @sim/setup update # pull and apply Compose images
npx @sim/setup status # what's installed and healthy
npx @sim/setup logs # follow logs
npx @sim/setup doctor # diagnose configuration problems
npx @sim/setup down # remove containers (data kept)
npx @sim/setup reset # archive .env and wipe managed data
```
`sim` detects how you're running (Docker Compose, local dev, or Kubernetes) and acts accordingly.
Prefer a bare `sim`? Run `bun link` once — but note `sim` lands in `~/.bun/bin`, which Homebrew's bun doesn't add to your PATH, so you may need `export PATH="$HOME/.bun/bin:$PATH"` in your shell profile.
The setup package detects how you're running and acts accordingly. Use `--dir <path>` to create or manage a deployment somewhere other than `./sim`.
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/). See the [self-hosting docs](https://docs.sim.ai/self-hosting/docker) for details.
## Chat API Keys
Chat is a Sim-managed service. `bun run setup` connects a Chat API key for you — sign in when it opens your browser and the key is stored automatically. To view, create, or revoke keys later, go to [sim.ai/selfhost/settings/chat-keys](https://sim.ai/selfhost/settings/chat-keys).
Chat is a Sim-managed service. `npx @sim/setup` connects a Chat API key for you — sign in when it opens your browser and the key is stored automatically. To view, create, or revoke keys later, go to [sim.ai/selfhost/settings/chat-keys](https://sim.ai/selfhost/settings/chat-keys).
## Environment Variables
@@ -10,20 +10,14 @@ import { FAQ } from '@/components/ui/faq'
## Quick Start
```bash
git clone https://github.com/simstudioai/sim.git && cd sim
cat > .env << EOF
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)
INTERNAL_API_SECRET=$(openssl rand -hex 32)
CRON_SECRET=$(openssl rand -hex 32)
EOF
docker compose -f docker-compose.prod.yml up -d
npx @sim/setup
```
Open [http://localhost:3000](http://localhost:3000)
The setup package creates `./sim` with a generated `.env` and the production Compose file, then
starts published container images. Pass `--dir <path>` to choose another directory.
## Production Setup
### 1. Configure Environment
@@ -150,7 +144,7 @@ docker compose -f docker-compose.prod.yml logs migrations
docker compose -f docker-compose.prod.yml logs -f cron
# Upgrade: bump SIM_VERSION in .env when pinned, then
bun run sim update
npx @sim/setup update
```
<FAQ items={[
@@ -178,7 +178,7 @@ See [Observability](/platform/self-hosting/observability).
| Variable | Description |
|----------|-------------|
| `COPILOT_API_KEY` | API key for Chat. Without it the Sim Chat block, scheduled prompt jobs, and Inbox cannot run |
| `NEXT_PUBLIC_CHAT_DISABLED` | Set to `true` to hide the Chat module: the workspace lands on your first workflow, with no chats list, scheduled tasks, or editor Chat panel. Chat is shown when unset; `bun run setup` sets it for you if you skip the chat key |
| `NEXT_PUBLIC_CHAT_DISABLED` | Set to `true` to hide the Chat module: the workspace lands on your first workflow, with no chats list, scheduled tasks, or editor Chat panel. Chat is shown when unset; `npx @sim/setup` sets it for you if you skip the chat key |
| `PII_REDACTION` | Redact PII from workflow logs via Data Retention rules; requires the PII service and a cluster-reachable `INTERNAL_API_BASE_URL` |
| `PII_GRANULAR_REDACTION` | Additionally expose the execution-altering redaction stages |
| `DURABLE_SECRET_PROVENANCE_ENFORCED_SURFACES` | Durable stores where a value whose secret provenance was never recorded fails the run instead of logging a warning. `all`, or a comma-separated subset of `memory`, `table-row`, `knowledge`. Unset (nothing enforced) by default |
@@ -142,11 +142,11 @@ kubectl logs -n simstudio deploy/sim-app -c migrations --tail=100
<Tab value="Docker Compose">
```bash
bun run sim update
npx @sim/setup update
docker compose -f docker-compose.prod.yml logs migrations
```
`bun run sim update` pulls the versions configured by `SIM_VERSION` (or `latest` when it is
`npx @sim/setup update` pulls the versions configured by `SIM_VERSION` (or `latest` when it is
unset), recreates the changed services, and keeps data volumes. It is equivalent to running
`docker compose pull` followed by `docker compose up -d`.
@@ -1,5 +1,5 @@
import integrationsJson from '@sim/deployment-config/integrations.json'
import { notFound } from 'next/navigation'
import integrationsJson from '@/lib/integrations/integrations.json'
import type { AuthType, Integration } from '@/lib/integrations/types'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
@@ -1,4 +1,4 @@
import integrationsJson from '@/lib/integrations/integrations.json'
import integrationsJson from '@sim/deployment-config/integrations.json'
import type { Integration } from '@/lib/integrations/types'
import { createLandingOgImage } from '@/app/(landing)/og-utils'
+1 -1
View File
@@ -698,7 +698,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
* two stay in lockstep without a separate registry to maintain.
*
* `BlockMeta` exists only for catalog-visible integrations — every key here
* has a corresponding entry in `lib/integrations/integrations.json`. Blocks
* has a corresponding entry in `packages/deployment-config/src/integrations.json`. Blocks
* absent from the catalog (core blocks like `agent`/`api`, superseded base
* versions, and hidden tools) carry no meta because the only consumers are
* integration surfaces: `getTemplatesForBlock` (the two integration detail
@@ -31,13 +31,13 @@ describe('server environment capabilities', () => {
expect(inspectConfiguredOAuthClient('slack')).toEqual({
state: 'partial',
missingFields: ['SLACK_CLIENT_SECRET'],
setupCommand: 'bun run setup integration slack',
setupCommand: 'npx @sim/setup add integration slack',
})
})
it('fails fast when an OAuth client is absent', () => {
expect(() => requireConfiguredOAuthClient('shopify')).toThrow(
'OAuth client shopify is not configured. Run bun run setup integration shopify.'
'OAuth client shopify is not configured. Run npx @sim/setup add integration shopify.'
)
})
@@ -45,7 +45,7 @@ describe('server environment capabilities', () => {
setEnv({ SLACK_CLIENT_ID: 'slack-client' })
expect(() => requireConfiguredOAuthClient('slack')).toThrow(
'OAuth client slack is partially configured — missing SLACK_CLIENT_SECRET. Run bun run setup integration slack.'
'OAuth client slack is partially configured — missing SLACK_CLIENT_SECRET. Run npx @sim/setup add integration slack.'
)
})
@@ -3,6 +3,7 @@
*
* @packageDocumentation
*/
import { env } from '@/lib/core/config/env'
import {
ASYNC_JOBS_CAPABILITY,
@@ -1,3 +1,4 @@
import integrationsJson from '@sim/deployment-config/integrations.json'
import { describe, expect, it, vi } from 'vitest'
import {
ASYNC_JOBS_CAPABILITY,
@@ -20,7 +21,6 @@ import {
validateCapabilityFieldInput,
wireFallback,
} from '@/lib/core/config/env-capabilities'
import integrationsJson from '@/lib/integrations/integrations.json'
import type { Integration } from '@/lib/integrations/types'
import { getServiceConfigByServiceId } from '@/lib/oauth/utils'
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* Loaded by `next.config.ts` before the `@/` alias is available, so
* config-boundary dependencies in this module must use relative imports.
* config-boundary dependencies use workspace packages or relative imports.
*/
import {
@@ -411,7 +411,7 @@ const sandboxProvider = inspectCapability(SANDBOX_CAPABILITY, env).providerId
*
* The browser cannot inspect provider credentials, so
* `NEXT_PUBLIC_SANDBOXES_ENABLED` is its readiness projection. Set the public
* value only after this server-side check succeeds; `bun run setup --doctor`
* value only after this server-side check succeeds; `npx @sim/setup doctor`
* reports mismatches in either direction.
*/
export const isRemoteSandboxEnabled =
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/config/env', () => ({ env: {} }))
import integrationsJson from '@sim/deployment-config/integrations.json'
import {
OAUTH_CLIENT_CAPABILITIES,
resolveOAuthClientCapabilityId,
@@ -20,7 +21,6 @@ import {
isIntegrationDeploymentAvailable,
isIntegrationDeploymentAvailableForVisibility,
} from '@/lib/integrations/availability.server'
import integrationsJson from '@/lib/integrations/integrations.json'
import { SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID } from '@/lib/integrations/service-account-metadata'
import type { Integration } from '@/lib/integrations/types'
import { getServiceConfigByServiceId } from '@/lib/oauth/utils'
@@ -50,7 +50,7 @@ describe('integration availability', () => {
oauthAvailable: true,
serviceAccountAvailable: false,
missingFields: [],
setupCommand: 'bun run setup integration slack',
setupCommand: 'npx @sim/setup add integration slack',
})
})
@@ -60,7 +60,7 @@ describe('integration availability', () => {
oauthAvailable: false,
serviceAccountAvailable: true,
missingFields: ['NOTION_CLIENT_ID', 'NOTION_CLIENT_SECRET'],
setupCommand: 'bun run setup integration notion',
setupCommand: 'npx @sim/setup add integration notion',
})
})
@@ -77,7 +77,7 @@ describe('integration availability', () => {
expect(availabilityFor('x')).toMatchObject({
state: 'unavailable',
oauthAvailable: false,
setupCommand: 'bun run setup integration x',
setupCommand: 'npx @sim/setup add integration x',
})
})
@@ -87,7 +87,7 @@ describe('integration availability', () => {
oauthAvailable: false,
serviceAccountAvailable: false,
missingFields: ['SLACK_CLIENT_SECRET'],
setupCommand: 'bun run setup integration slack',
setupCommand: 'npx @sim/setup add integration slack',
})
})
@@ -158,7 +158,7 @@ describe('integration availability', () => {
state: 'unavailable',
serviceAccountAvailable: false,
missingFields: ['TRELLO_API_KEY'],
setupCommand: 'bun run setup integration trello',
setupCommand: 'npx @sim/setup add integration trello',
})
expect(availabilityFor('trello', { TRELLO_API_KEY: 'trello-key' })).toMatchObject({
state: 'ready',
@@ -181,7 +181,7 @@ describe('integration availability', () => {
for (const integration of availability) {
if (!integration.setupCommand) continue
const capabilityId = integration.setupCommand.replace('bun run setup integration ', '')
const capabilityId = integration.setupCommand.replace('npx @sim/setup add integration ', '')
expect(Object.hasOwn(OAUTH_CLIENT_CAPABILITIES, capabilityId)).toBe(true)
}
})
+20 -148
View File
@@ -1,82 +1,22 @@
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
import type { EnvCapabilityValues } from '@/lib/core/config/env-capabilities'
import {
inspectOAuthClientCapability,
resolveOAuthClientCapabilityId,
} from '@/lib/core/config/env-capabilities'
getPreviewServiceAccountProviderId,
type IntegrationAvailabilityState,
} from '@sim/deployment-config/integration-availability'
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids'
import integrationsJson from '@/lib/integrations/integrations.json'
import { getServiceAccountMetadata } from '@/lib/integrations/service-account-metadata'
import { isHiddenUnder } from '@/blocks/visibility/context'
export type IntegrationAvailabilityState = 'ready' | 'limited' | 'unavailable' | 'misconfigured'
export interface IntegrationAvailability {
type: string
slug: string
name: string
state: IntegrationAvailabilityState
oauthAvailable: boolean
serviceAccountAvailable: boolean
missingFields: readonly string[]
setupCommand?: string
}
interface DeploymentIntegration {
type: string
slug: string
name: string
authType: 'oauth' | 'api-key' | 'none'
oauthServiceId?: string
}
const integrations = integrationsJson.integrations as readonly DeploymentIntegration[]
const deploymentGatedIntegrationTypes = new Set(
integrations
.filter((integration) => integration.authType === 'oauth')
.map((integration) => integration.type.toLowerCase())
)
const integrationTypesByOAuthServiceId = new Map<string, readonly string[]>()
const previewServiceAccountGatesByIntegrationType = new Map<string, string>()
for (const integration of integrations) {
if (integration.authType !== 'oauth' || !integration.oauthServiceId) continue
const serviceId = integration.oauthServiceId.toLowerCase()
const current = integrationTypesByOAuthServiceId.get(serviceId) ?? []
const integrationType = integration.type.toLowerCase()
integrationTypesByOAuthServiceId.set(serviceId, [...current, integrationType])
const serviceAccount = getServiceAccountMetadata(serviceId)
if (serviceAccount?.deploymentRequirement !== 'preview-gated') continue
const gatingBlockType = getServiceAccountGatingBlockType(serviceAccount.providerId)
if (!gatingBlockType) {
throw new Error(
`Preview-gated service account ${serviceAccount.providerId} has no gating block type`
)
}
previewServiceAccountGatesByIntegrationType.set(integrationType, gatingBlockType)
}
export function isDeploymentGatedIntegrationType(blockType: string): boolean {
return deploymentGatedIntegrationTypes.has(blockType.toLowerCase())
}
/** Returns the generated integration block types authenticated by one OAuth service entry. */
export function getIntegrationTypesForOAuthServiceId(serviceId: string): readonly string[] {
return integrationTypesByOAuthServiceId.get(serviceId.toLowerCase()) ?? []
}
/** Applies an integration allowlist to an OAuth service without loading executable registries. */
export function isOAuthServiceAllowedByIntegrationTypes(
serviceId: string,
allowedIntegrationTypes: ReadonlySet<string> | null
): boolean {
if (allowedIntegrationTypes === null) return true
const integrationTypes = getIntegrationTypesForOAuthServiceId(serviceId)
return (
integrationTypes.length === 0 ||
integrationTypes.some((blockType) => allowedIntegrationTypes.has(blockType))
)
}
export type {
IntegrationAvailability,
IntegrationAvailabilityState,
} from '@sim/deployment-config/integration-availability'
/** Application compatibility surface for pure deployment availability helpers. */
export {
getIntegrationTypesForOAuthServiceId,
isDeploymentGatedIntegrationType,
isOAuthServiceAllowedByIntegrationTypes,
resolveIntegrationAvailability,
} from '@sim/deployment-config/integration-availability'
interface IntegrationAvailabilitySummary {
type: string
@@ -95,81 +35,13 @@ export function resolveIntegrationAvailabilityStateForVisibility(
availability: IntegrationAvailabilitySummary,
visibility: BlockVisibilityState | null
): IntegrationAvailabilityState {
const gatingBlockType = previewServiceAccountGatesByIntegrationType.get(
availability.type.toLowerCase()
)
const providerId = getPreviewServiceAccountProviderId(availability.type)
const gatingBlockType = providerId ? getServiceAccountGatingBlockType(providerId) : null
if (providerId && !gatingBlockType) {
throw new Error(`Preview-gated service account ${providerId} has no gating block type`)
}
if (!gatingBlockType || isHiddenUnder(visibility, { type: gatingBlockType, preview: true })) {
return availability.state
}
return availability.oauthAvailable ? 'ready' : 'limited'
}
function resolveOAuthIntegrationAvailability(
integration: DeploymentIntegration,
values: EnvCapabilityValues
): IntegrationAvailability {
const { oauthServiceId } = integration
if (!oauthServiceId) {
throw new Error(`OAuth integration ${integration.slug} is missing oauthServiceId`)
}
const capabilityId = resolveOAuthClientCapabilityId(oauthServiceId)
const serviceAccount = getServiceAccountMetadata(oauthServiceId)
if (!capabilityId) {
throw new Error(
`OAuth integration ${integration.slug} has no OAuth client capability definition`
)
}
const oauth = inspectOAuthClientCapability(capabilityId, values)
const setupCommand = `bun run setup integration ${capabilityId}`
const serviceAccountAvailable = Boolean(
serviceAccount &&
serviceAccount.deploymentRequirement !== 'preview-gated' &&
(serviceAccount.deploymentRequirement !== 'oauth-client' || oauth.state === 'ready')
)
const state: IntegrationAvailabilityState =
oauth.state === 'ready'
? 'ready'
: serviceAccountAvailable
? 'limited'
: oauth.state === 'partial' || oauth.state === 'invalid'
? 'misconfigured'
: 'unavailable'
return {
type: integration.type,
slug: integration.slug,
name: integration.name,
state,
oauthAvailable: oauth.state === 'ready',
serviceAccountAvailable,
missingFields: oauth.missingFields,
setupCommand,
}
}
/**
* Resolves deployment availability for every integration in the generated
* catalog using only caller-supplied environment values and pure metadata.
*/
export function resolveIntegrationAvailability(
values: EnvCapabilityValues
): readonly IntegrationAvailability[] {
return integrations.map((integration) => {
if (integration.authType === 'oauth') {
return resolveOAuthIntegrationAvailability(integration, values)
}
return {
type: integration.type,
slug: integration.slug,
name: integration.name,
state: 'ready',
oauthAvailable: false,
serviceAccountAvailable: false,
missingFields: [],
}
})
}
@@ -1,6 +1,8 @@
/**
* @vitest-environment node
*/
import integrationsJson from '@sim/deployment-config/integrations.json'
import { describe, expect, it } from 'vitest'
import {
getIntegrationsForCredentialProvider,
@@ -9,7 +11,6 @@ import {
isFamilyServiceAccount,
resolveCredentialDisplay,
} from '@/lib/integrations/credential-display'
import integrationsJson from '@/lib/integrations/integrations.json'
import { resolveOAuthServiceForIntegration } from '@/lib/integrations/oauth-service'
import type { Integration } from '@/lib/integrations/types'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
@@ -11,8 +11,8 @@
*/
import type { ComponentType } from 'react'
import integrationsJson from '@sim/deployment-config/integrations.json'
import { getServiceAccountConnectNoun } from '@/lib/credentials/service-account-provider-ids'
import integrationsJson from '@/lib/integrations/integrations.json'
import { CANONICAL_SERVICE_ACCOUNT_SLUGS } from '@/lib/integrations/oauth-service'
import type { Integration } from '@/lib/integrations/types'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
@@ -1,6 +1,7 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IntegrationAvailability } from '@/lib/integrations/availability'
import type { OAuthServiceMetadata } from '@/lib/oauth/types'
+1 -1
View File
@@ -14,7 +14,7 @@
* imports are fine — they erase.
*/
import integrationsJson from '@/lib/integrations/integrations.json'
import integrationsJson from '@sim/deployment-config/integrations.json'
import type { Integration, IntegrationSummary } from '@/lib/integrations/types'
/** All integrations surfaced in the catalog, ordered by `scripts/generate-docs.ts`. */
@@ -1,8 +1,9 @@
/**
* @vitest-environment node
*/
import integrationsJson from '@sim/deployment-config/integrations.json'
import { describe, expect, it } from 'vitest'
import integrationsJson from '@/lib/integrations/integrations.json'
import {
resolveOAuthServiceForSlug,
resolveServiceAccountIntegration,
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ComponentType } from 'react'
import integrationsJson from '@sim/deployment-config/integrations.json'
import { asServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids'
import integrationsJson from '@/lib/integrations/integrations.json'
import type { Integration } from '@/lib/integrations/types'
import { getServiceConfigByServiceId } from '@/lib/oauth'
import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
@@ -1,61 +1,2 @@
/**
* Lightweight deployment metadata for OAuth services that also accept a
* user-supplied service-account credential.
*
* This projection deliberately contains no icons, scopes, or OAuth runtime
* configuration so deployment tooling can inspect integration availability
* without loading the executable integration graph. Its parity with the
* canonical OAuth service configuration is enforced by an invariant test.
*/
export interface ServiceAccountMetadata {
providerId: string
deploymentRequirement?: 'preview-gated' | 'oauth-client'
}
export const SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID: Readonly<
Record<string, ServiceAccountMetadata>
> = {
airtable: { providerId: 'airtable-service-account' },
asana: { providerId: 'asana-service-account' },
attio: { providerId: 'attio-service-account' },
box: { providerId: 'box-service-account' },
calcom: { providerId: 'calcom-service-account' },
clickup: { providerId: 'clickup-service-account' },
confluence: { providerId: 'atlassian-service-account' },
gmail: { providerId: 'google-service-account' },
'google-bigquery': { providerId: 'google-service-account' },
'google-calendar': { providerId: 'google-service-account' },
'google-contacts': { providerId: 'google-service-account' },
'google-docs': { providerId: 'google-service-account' },
'google-drive': { providerId: 'google-service-account' },
'google-forms': { providerId: 'google-service-account' },
'google-groups': { providerId: 'google-service-account' },
'google-meet': { providerId: 'google-service-account' },
'google-sheets': { providerId: 'google-service-account' },
'google-tasks': { providerId: 'google-service-account' },
'google-vault': { providerId: 'google-service-account' },
hubspot: { providerId: 'hubspot-service-account' },
jira: { providerId: 'atlassian-service-account' },
linear: { providerId: 'linear-service-account' },
monday: { providerId: 'monday-service-account' },
notion: { providerId: 'notion-service-account' },
pipedrive: { providerId: 'pipedrive-service-account' },
salesforce: { providerId: 'salesforce-service-account' },
shopify: { providerId: 'shopify-service-account' },
slack: { providerId: 'slack-custom-bot', deploymentRequirement: 'preview-gated' },
trello: { providerId: 'trello-service-account', deploymentRequirement: 'oauth-client' },
wealthbox: { providerId: 'wealthbox-service-account' },
webflow: { providerId: 'webflow-service-account' },
'zoho-desk': { providerId: 'zoho-desk-service-account' },
zoom: { providerId: 'zoom-service-account' },
} as const
export function getServiceAccountMetadata(
oauthServiceId: string
): ServiceAccountMetadata | undefined {
if (!Object.hasOwn(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID, oauthServiceId)) {
return undefined
}
return SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID[oauthServiceId]
}
/** Application compatibility surface for shared service-account deployment metadata. */
export * from '@sim/deployment-config/service-account-metadata'
+1 -1
View File
@@ -413,7 +413,7 @@ describe('OAuth Token Refresh', () => {
expect(result).toEqual({
ok: false,
message:
'OAuth client monday is partially configured — missing MONDAY_CLIENT_SECRET. Run bun run setup integration monday.',
'OAuth client monday is partially configured — missing MONDAY_CLIENT_SECRET. Run npx @sim/setup add integration monday.',
})
expect(mockFetch).not.toHaveBeenCalled()
})
+1
View File
@@ -109,6 +109,7 @@
"@sim/audit": "workspace:*",
"@sim/auth": "workspace:*",
"@sim/browser-protocol": "workspace:*",
"@sim/deployment-config": "workspace:*",
"@sim/desktop-bridge": "workspace:*",
"@sim/emcn": "workspace:*",
"@sim/logger": "workspace:*",
@@ -33,7 +33,7 @@ function loadToolDescriptions(): Map<string, Map<string, string>> {
const byType = new Map<string, Map<string, string>>()
try {
const catalog = JSON.parse(
readFileSync('apps/sim/lib/integrations/integrations.json', 'utf-8')
readFileSync('packages/deployment-config/src/integrations.json', 'utf-8')
) as {
integrations: Array<{
type: string
+1 -1
View File
@@ -49,7 +49,7 @@ function loadToolDescriptions(type: string): Map<string, string> {
const byLabel = new Map<string, string>()
try {
const catalog = JSON.parse(
readFileSync('apps/sim/lib/integrations/integrations.json', 'utf-8')
readFileSync('packages/deployment-config/src/integrations.json', 'utf-8')
) as {
integrations: Array<{
type: string
+36
View File
@@ -212,6 +212,7 @@
"@sim/audit": "workspace:*",
"@sim/auth": "workspace:*",
"@sim/browser-protocol": "workspace:*",
"@sim/deployment-config": "workspace:*",
"@sim/desktop-bridge": "workspace:*",
"@sim/emcn": "workspace:*",
"@sim/logger": "workspace:*",
@@ -450,6 +451,17 @@
"typescript": "^7.0.2",
},
},
"packages/deployment-config": {
"name": "@sim/deployment-config",
"version": "0.1.0",
"dependencies": {
"@sim/utils": "workspace:*",
},
"devDependencies": {
"@sim/tsconfig": "workspace:*",
"typescript": "^7.0.2",
},
},
"packages/desktop-bridge": {
"name": "@sim/desktop-bridge",
"version": "0.1.0",
@@ -607,6 +619,26 @@
"vitest": "^4.1.0",
},
},
"packages/sim-setup": {
"name": "@sim/setup",
"version": "1.0.0",
"bin": {
"sim-setup": "dist/index.js",
},
"devDependencies": {
"@clack/prompts": "1.7.0",
"@next/env": "16.2.12",
"@sim/deployment-config": "workspace:*",
"@sim/security": "workspace:*",
"@sim/tsconfig": "workspace:*",
"@sim/utils": "workspace:*",
"@types/node": "24.2.1",
"chalk": "5.6.2",
"postgres": "^3.4.5",
"typescript": "^7.0.2",
"vitest": "^4.1.0",
},
},
"packages/terminal-protocol": {
"name": "@sim/terminal-protocol",
"version": "0.1.0",
@@ -1785,6 +1817,8 @@
"@sim/db": ["@sim/db@workspace:packages/db"],
"@sim/deployment-config": ["@sim/deployment-config@workspace:packages/deployment-config"],
"@sim/desktop": ["@sim/desktop@workspace:apps/desktop"],
"@sim/desktop-bridge": ["@sim/desktop-bridge@workspace:packages/desktop-bridge"],
@@ -1805,6 +1839,8 @@
"@sim/security": ["@sim/security@workspace:packages/security"],
"@sim/setup": ["@sim/setup@workspace:packages/sim-setup"],
"@sim/terminal-protocol": ["@sim/terminal-protocol@workspace:packages/terminal-protocol"],
"@sim/testing": ["@sim/testing@workspace:packages/testing"],
-6
View File
@@ -134,12 +134,6 @@ services:
# and shares no database schema with the app, so it does not need to move in
# lockstep — and no cron tag exists for releases that predate it.
image: ghcr.io/simstudioai/cron:${SIM_CRON_VERSION:-latest}
# Built from the repo when the published image is not available locally, so a
# fresh `git clone && docker compose up -d` works before the first release
# that publishes it.
build:
context: .
dockerfile: docker/cron.Dockerfile
# on-failure, not unless-stopped: with no CRON_SECRET the container exits 0
# after explaining why, and stays stopped instead of crash-looping. Upgrades
# from a compose file that predates this service therefore still come up.
+4 -7
View File
@@ -8,9 +8,6 @@
"apps/*",
"packages/*"
],
"bin": {
"sim": "./scripts/setup/launcher.ts"
},
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
@@ -18,7 +15,7 @@
"dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"",
"dev:full:capped": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev:capped\" \"cd apps/realtime && bun run dev\"",
"test": "bun run test:setup && turbo run test",
"test:setup": "bun test scripts/setup",
"test:setup": "bun run --cwd packages/sim-setup test",
"format": "turbo run format",
"format:check": "turbo run format:check",
"lint": "turbo run lint",
@@ -64,6 +61,8 @@
"billing-protocol-contract:check": "bun run scripts/sync-billing-protocol-contract.ts --check",
"tool-metadata:generate": "bun run scripts/sync-tool-metadata.ts",
"tool-metadata:check": "bun run scripts/sync-tool-metadata.ts --check",
"deployment-config:generate": "bun run scripts/generate-deployment-config.ts",
"deployment-config:check": "bun run scripts/generate-deployment-config.ts --check",
"integration-catalog:check": "bun run scripts/check-integration-catalog.ts",
"docs:check": "bun run scripts/generate-docs.ts --check",
"mship-tools:generate": "bun run scripts/sync-tool-catalog.ts",
@@ -85,9 +84,7 @@
"library:covers": "bun run scripts/generate-library-covers.tsx",
"library:covers:check": "bun run scripts/generate-library-covers.tsx --check",
"skills:sync": "bun run scripts/sync-skills.ts",
"setup": "bun run scripts/setup/launcher.ts setup",
"sim": "bun run scripts/setup/launcher.ts",
"doctor": "bun run scripts/setup/launcher.ts doctor",
"sim-setup": "bun run packages/sim-setup/src/index.ts",
"agent-stream-docs:generate": "bun run scripts/sync-agent-stream-docs.ts",
"agent-stream-docs:check": "bun run scripts/sync-agent-stream-docs.ts --check",
"prepare": "bun husky",
+1 -1
View File
@@ -44,7 +44,7 @@ const SECRET_KEYS = [
* Placeholders are rejected at any length: the repository publishes example values longer
* than the 32-character minimum, so a copied `.env.example` would otherwise pass as real.
*
* Mirrors `isUsableSecret` in `scripts/setup/env-files.ts`.
* Mirrors `isUsableSecret` in `packages/sim-setup/src/env-files.ts`.
*/
const AES_KEY_PATTERN = /^[0-9a-f]{64}$/i
const AES_SECRET_KEYS = new Set<string>(['ENCRYPTION_KEY', 'API_ENCRYPTION_KEY'])
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@sim/deployment-config",
"version": "0.1.0",
"private": true,
"sideEffects": false,
"type": "module",
"license": "Apache-2.0",
"exports": {
"./env-capabilities": {
"types": "./src/env-capabilities.ts",
"default": "./src/env-capabilities.ts"
},
"./integration-availability": {
"types": "./src/integration-availability.ts",
"default": "./src/integration-availability.ts"
},
"./integrations.json": "./src/integrations.json",
"./service-account-metadata": {
"types": "./src/service-account-metadata.ts",
"default": "./src/service-account-metadata.ts"
}
},
"scripts": {
"type-check": "tsc --noEmit",
"lint": "biome check --write --unsafe .",
"lint:check": "biome check .",
"format": "biome format --write .",
"format:check": "biome format ."
},
"dependencies": {
"@sim/utils": "workspace:*"
},
"devDependencies": {
"@sim/tsconfig": "workspace:*",
"typescript": "^7.0.2"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
import type { EnvCapabilityValues } from './env-capabilities'
import { inspectOAuthClientCapability, resolveOAuthClientCapabilityId } from './env-capabilities'
import integrationsJson from './integrations.json'
import { getServiceAccountMetadata } from './service-account-metadata'
export type IntegrationAvailabilityState = 'ready' | 'limited' | 'unavailable' | 'misconfigured'
export interface IntegrationAvailability {
type: string
slug: string
name: string
state: IntegrationAvailabilityState
oauthAvailable: boolean
serviceAccountAvailable: boolean
missingFields: readonly string[]
setupCommand?: string
}
interface DeploymentIntegration {
type: string
slug: string
name: string
authType: 'oauth' | 'api-key' | 'none'
oauthServiceId?: string
}
const integrations = integrationsJson.integrations as readonly DeploymentIntegration[]
const deploymentGatedIntegrationTypes = new Set(
integrations
.filter((integration) => integration.authType === 'oauth')
.map((integration) => integration.type.toLowerCase())
)
const integrationTypesByOAuthServiceId = new Map<string, readonly string[]>()
const previewServiceAccountProvidersByIntegrationType = new Map<string, string>()
for (const integration of integrations) {
if (integration.authType !== 'oauth' || !integration.oauthServiceId) continue
const serviceId = integration.oauthServiceId.toLowerCase()
const current = integrationTypesByOAuthServiceId.get(serviceId) ?? []
const integrationType = integration.type.toLowerCase()
integrationTypesByOAuthServiceId.set(serviceId, [...current, integrationType])
const serviceAccount = getServiceAccountMetadata(serviceId)
if (serviceAccount?.deploymentRequirement !== 'preview-gated') continue
previewServiceAccountProvidersByIntegrationType.set(integrationType, serviceAccount.providerId)
}
export function isDeploymentGatedIntegrationType(blockType: string): boolean {
return deploymentGatedIntegrationTypes.has(blockType.toLowerCase())
}
/** Returns the generated integration block types authenticated by one OAuth service entry. */
export function getIntegrationTypesForOAuthServiceId(serviceId: string): readonly string[] {
return integrationTypesByOAuthServiceId.get(serviceId.toLowerCase()) ?? []
}
/** Applies an integration allowlist to an OAuth service without loading executable registries. */
export function isOAuthServiceAllowedByIntegrationTypes(
serviceId: string,
allowedIntegrationTypes: ReadonlySet<string> | null
): boolean {
if (allowedIntegrationTypes === null) return true
const integrationTypes = getIntegrationTypesForOAuthServiceId(serviceId)
return (
integrationTypes.length === 0 ||
integrationTypes.some((blockType) => allowedIntegrationTypes.has(blockType))
)
}
/** Returns the preview-gated service-account provider for an integration type. */
export function getPreviewServiceAccountProviderId(integrationType: string): string | undefined {
return previewServiceAccountProvidersByIntegrationType.get(integrationType.toLowerCase())
}
function resolveOAuthIntegrationAvailability(
integration: DeploymentIntegration,
values: EnvCapabilityValues
): IntegrationAvailability {
const { oauthServiceId } = integration
if (!oauthServiceId) {
throw new Error(`OAuth integration ${integration.slug} is missing oauthServiceId`)
}
const capabilityId = resolveOAuthClientCapabilityId(oauthServiceId)
const serviceAccount = getServiceAccountMetadata(oauthServiceId)
if (!capabilityId) {
throw new Error(
`OAuth integration ${integration.slug} has no OAuth client capability definition`
)
}
const oauth = inspectOAuthClientCapability(capabilityId, values)
const setupCommand = `npx @sim/setup add integration ${capabilityId}`
const serviceAccountAvailable = Boolean(
serviceAccount &&
serviceAccount.deploymentRequirement !== 'preview-gated' &&
(serviceAccount.deploymentRequirement !== 'oauth-client' || oauth.state === 'ready')
)
const state: IntegrationAvailabilityState =
oauth.state === 'ready'
? 'ready'
: serviceAccountAvailable
? 'limited'
: oauth.state === 'partial' || oauth.state === 'invalid'
? 'misconfigured'
: 'unavailable'
return {
type: integration.type,
slug: integration.slug,
name: integration.name,
state,
oauthAvailable: oauth.state === 'ready',
serviceAccountAvailable,
missingFields: oauth.missingFields,
setupCommand,
}
}
/**
* Resolves deployment availability for every integration in the generated
* catalog using only caller-supplied environment values and pure metadata.
*/
export function resolveIntegrationAvailability(
values: EnvCapabilityValues
): readonly IntegrationAvailability[] {
return integrations.map((integration) => {
if (integration.authType === 'oauth') {
return resolveOAuthIntegrationAvailability(integration, values)
}
return {
type: integration.type,
slug: integration.slug,
name: integration.name,
state: 'ready',
oauthAvailable: false,
serviceAccountAvailable: false,
missingFields: [],
}
})
}
@@ -0,0 +1,48 @@
import { SERVICE_ACCOUNT_PROVIDER_BY_OAUTH_SERVICE_ID } from './service-account-providers.generated'
type DeploymentRequirement = 'preview-gated' | 'oauth-client'
type ServiceAccountOAuthServiceId = keyof typeof SERVICE_ACCOUNT_PROVIDER_BY_OAUTH_SERVICE_ID
export interface ServiceAccountMetadata {
providerId: string
deploymentRequirement?: DeploymentRequirement
}
/** Handwritten deployment policy layered over generated OAuth registry facts. */
const DEPLOYMENT_REQUIREMENT_BY_OAUTH_SERVICE_ID = {
slack: 'preview-gated',
trello: 'oauth-client',
} as const satisfies Partial<Record<ServiceAccountOAuthServiceId, DeploymentRequirement>>
function getDeploymentRequirement(oauthServiceId: string): DeploymentRequirement | undefined {
if (!Object.hasOwn(DEPLOYMENT_REQUIREMENT_BY_OAUTH_SERVICE_ID, oauthServiceId)) return undefined
return DEPLOYMENT_REQUIREMENT_BY_OAUTH_SERVICE_ID[
oauthServiceId as keyof typeof DEPLOYMENT_REQUIREMENT_BY_OAUTH_SERVICE_ID
]
}
function buildServiceAccountMetadata(): Readonly<Record<string, ServiceAccountMetadata>> {
const metadata: Record<string, ServiceAccountMetadata> = {}
for (const [oauthServiceId, providerId] of Object.entries(
SERVICE_ACCOUNT_PROVIDER_BY_OAUTH_SERVICE_ID
)) {
const deploymentRequirement = getDeploymentRequirement(oauthServiceId)
metadata[oauthServiceId] = {
providerId,
...(deploymentRequirement ? { deploymentRequirement } : {}),
}
}
return metadata
}
/** Lightweight deployment metadata safe to consume outside the application graph. */
export const SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID = buildServiceAccountMetadata()
export function getServiceAccountMetadata(
oauthServiceId: string
): ServiceAccountMetadata | undefined {
if (!Object.hasOwn(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID, oauthServiceId)) {
return undefined
}
return SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID[oauthServiceId]
}
@@ -0,0 +1,39 @@
/**
* Generated by `bun run deployment-config:generate` from the canonical OAuth
* registry and integration catalog. Do not edit this file directly.
*/
export const SERVICE_ACCOUNT_PROVIDER_BY_OAUTH_SERVICE_ID = {
airtable: 'airtable-service-account',
asana: 'asana-service-account',
attio: 'attio-service-account',
box: 'box-service-account',
calcom: 'calcom-service-account',
clickup: 'clickup-service-account',
confluence: 'atlassian-service-account',
gmail: 'google-service-account',
'google-bigquery': 'google-service-account',
'google-calendar': 'google-service-account',
'google-contacts': 'google-service-account',
'google-docs': 'google-service-account',
'google-drive': 'google-service-account',
'google-forms': 'google-service-account',
'google-groups': 'google-service-account',
'google-meet': 'google-service-account',
'google-sheets': 'google-service-account',
'google-tasks': 'google-service-account',
'google-vault': 'google-service-account',
hubspot: 'hubspot-service-account',
jira: 'atlassian-service-account',
linear: 'linear-service-account',
monday: 'monday-service-account',
notion: 'notion-service-account',
pipedrive: 'pipedrive-service-account',
salesforce: 'salesforce-service-account',
shopify: 'shopify-service-account',
slack: 'slack-custom-bot',
trello: 'trello-service-account',
wealthbox: 'wealthbox-service-account',
webflow: 'webflow-service-account',
'zoho-desk': 'zoho-desk-service-account',
zoom: 'zoom-service-account',
} as const
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "@sim/tsconfig/base.json",
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Sim Studio, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+11
View File
@@ -0,0 +1,11 @@
# @sim/setup
Set up and manage a self-hosted Sim installation.
```bash
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.
+55
View File
@@ -0,0 +1,55 @@
The @sim/setup bundle includes the following third-party software.
@clack/core and @clack/prompts
Copyright (c) 2025-Present Bombshell contributors
@next/env
Copyright (c) 2016-present Vercel, Inc.
chalk
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
The dependencies above are licensed under the MIT License:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
postgres
Copyright (c) Rasmus Porsager <rasmus@porsager.com> (https://www.porsager.com)
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
software, either in source code form or as a compiled binary, for any purpose,
commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this
software dedicate any and all copyright interest in the software to the public
domain. We make this dedication for the benefit of the public at large and to
the detriment of our heirs and successors. We intend this dedication to be an
overt act of relinquishment in perpetuity of all present and future rights to
this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>.
+63
View File
@@ -0,0 +1,63 @@
{
"name": "@sim/setup",
"version": "1.0.0",
"description": "Set up and manage a self-hosted Sim installation",
"type": "module",
"bin": {
"sim-setup": "dist/index.js"
},
"scripts": {
"prebuild": "bun run clean",
"build": "bun build src/index.ts --target=node --format=esm --packages=bundle --reject-unresolved --outfile=dist/index.js && bun run build:assets",
"build:assets": "bun run src/build-assets.ts",
"clean": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"",
"type-check": "tsc --noEmit",
"lint": "biome check --write --unsafe .",
"lint:check": "biome check .",
"format": "biome format --write .",
"format:check": "biome format .",
"test": "vitest run",
"prepublishOnly": "bun run build"
},
"files": [
"dist",
"THIRD_PARTY_LICENSES"
],
"keywords": [
"sim",
"self-hosted",
"docker",
"compose",
"setup"
],
"author": "Sim",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/simstudioai/sim.git",
"directory": "packages/sim-setup"
},
"homepage": "https://github.com/simstudioai/sim/tree/main/packages/sim-setup#readme",
"bugs": {
"url": "https://github.com/simstudioai/sim/issues"
},
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"@clack/prompts": "1.7.0",
"@next/env": "16.2.12",
"@sim/deployment-config": "workspace:*",
"@sim/security": "workspace:*",
"@sim/tsconfig": "workspace:*",
"@sim/utils": "workspace:*",
"@types/node": "24.2.1",
"chalk": "5.6.2",
"postgres": "^3.4.5",
"typescript": "^7.0.2",
"vitest": "^4.1.0"
}
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { LIFECYCLE_COMMANDS, parseSetupArguments } from './arguments'
describe('parseSetupArguments', () => {
it('parses wizard options in either supported value form', () => {
expect(parseSetupArguments(['--quick', '--mode', 'compose', '--dir', 'install'])).toEqual({
kind: 'wizard',
quick: true,
mode: 'compose',
})
expect(parseSetupArguments(['--mode=k8s', '--dir=install'])).toEqual({
kind: 'wizard',
quick: false,
mode: 'k8s',
})
})
it('rejects unknown and duplicate wizard options', () => {
expect(() => parseSetupArguments(['--quik'])).toThrow('Unknown setup option: --quik')
expect(() => parseSetupArguments(['--quick', '--quick'])).toThrow(
'--quick may only be provided once'
)
expect(() => parseSetupArguments(['--mode', 'production'])).toThrow(
'expected compose, dev, or k8s'
)
})
it('requires one unambiguous directory override', () => {
expect(() => parseSetupArguments(['--dir'])).toThrow('--dir requires a directory path')
expect(() => parseSetupArguments(['--dir='])).toThrow('--dir requires a directory path')
expect(() => parseSetupArguments(['--dir=a', '--dir', 'b'])).toThrow(
'--dir may only be provided once'
)
})
it('rejects extra lifecycle and configuration arguments', () => {
for (const command of [...LIFECYCLE_COMMANDS, 'config'] as const) {
expect(() => parseSetupArguments([command, 'extra'])).toThrow('does not accept: extra')
}
expect(() => parseSetupArguments(['doctor', '--json', '--unknown'])).toThrow(
'Unknown doctor option: --unknown'
)
})
it('validates add operands before loading configuration', () => {
expect(parseSetupArguments(['add', 'email'])).toEqual({
kind: 'add',
feature: 'email',
args: [],
})
expect(parseSetupArguments(['add', 'integration', 'slack'])).toEqual({
kind: 'add',
feature: 'integration',
args: ['slack'],
})
expect(() => parseSetupArguments(['add'])).toThrow('add requires a feature')
expect(() => parseSetupArguments(['add', 'integration'])).toThrow(
'requires exactly one integration slug'
)
expect(() => parseSetupArguments(['add', 'email', 'extra'])).toThrow(
'add email does not accept: extra'
)
})
it('allows help for every top-level command without executing it', () => {
expect(parseSetupArguments(['--help'])).toEqual({ kind: 'help' })
expect(parseSetupArguments(['add', '--help'])).toEqual({ kind: 'help' })
expect(parseSetupArguments(['add', 'integration', '--help'])).toEqual({ kind: 'help' })
expect(parseSetupArguments(['doctor', '--help'])).toEqual({ kind: 'help' })
for (const command of [...LIFECYCLE_COMMANDS, 'config'] as const) {
expect(parseSetupArguments([command, '--help'])).toEqual({ kind: 'help' })
}
})
it('keeps version root-only and rejects conflicting global flags', () => {
expect(parseSetupArguments(['--version'])).toEqual({ kind: 'version' })
expect(() => parseSetupArguments(['doctor', '--version'])).toThrow(
'--version does not accept: doctor'
)
expect(() => parseSetupArguments(['--help', '--version'])).toThrow(
'--help and --version cannot be combined'
)
})
})
+174
View File
@@ -0,0 +1,174 @@
export type WizardMode = 'compose' | 'dev' | 'k8s'
export const LIFECYCLE_COMMANDS = [
'start',
'stop',
'restart',
'update',
'status',
'logs',
'down',
'reset',
] as const
export type LifecycleCommand = (typeof LIFECYCLE_COMMANDS)[number]
export type SetupInvocation =
| { kind: 'help' }
| { kind: 'version' }
| { kind: 'wizard'; quick: boolean; mode?: WizardMode }
| { kind: 'config' }
| { kind: 'add'; feature: string; args: string[] }
| { kind: 'doctor'; fix: boolean; json: boolean }
| { kind: 'lifecycle'; command: LifecycleCommand }
export class SetupArgumentError extends Error {
constructor(message: string) {
super(message)
this.name = 'SetupArgumentError'
}
}
function isLifecycleCommand(value: string | undefined): value is LifecycleCommand {
return Boolean(value && (LIFECYCLE_COMMANDS as readonly string[]).includes(value))
}
function fail(message: string): never {
throw new SetupArgumentError(message)
}
function stripDirectoryOption(args: readonly string[]): string[] {
const filtered: string[] = []
let found = false
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--dir' || arg.startsWith('--dir=')) {
if (found) fail('--dir may only be provided once')
found = true
if (arg === '--dir') {
const value = args[index + 1]
if (!value || value.startsWith('-')) fail('--dir requires a directory path')
index += 1
} else if (!arg.slice('--dir='.length)) {
fail('--dir requires a directory path')
}
continue
}
filtered.push(arg)
}
return filtered
}
function oneFlag(args: readonly string[], flag: string): boolean {
const count = args.filter((arg) => arg === flag).length
if (count > 1) fail(`${flag} may only be provided once`)
return count === 1
}
function parseMode(args: readonly string[]): { mode?: WizardMode; remaining: string[] } {
let mode: WizardMode | undefined
const remaining: string[] = []
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg !== '--mode' && !arg.startsWith('--mode=')) {
remaining.push(arg)
continue
}
if (mode) fail('--mode may only be provided once')
const value = arg === '--mode' ? args[++index] : arg.slice('--mode='.length)
if (value !== 'compose' && value !== 'dev' && value !== 'k8s') {
fail(`invalid --mode "${value ?? ''}" — expected compose, dev, or k8s`)
}
mode = value
}
return { mode, remaining }
}
function expectNoArguments(command: string, args: readonly string[]): void {
if (args.length > 0) fail(`${command} does not accept: ${args.join(' ')}`)
}
function parseCore(
args: readonly string[],
helpRequested: boolean
): Exclude<SetupInvocation, { kind: 'help' | 'version' }> {
const command = args[0]
if (!command || command.startsWith('-')) {
const quick = oneFlag(args, '--quick')
const withoutQuick = args.filter((arg) => arg !== '--quick')
const { mode, remaining } = parseMode(withoutQuick)
if (remaining.length > 0) fail(`Unknown setup option: ${remaining[0]}`)
return { kind: 'wizard', quick, ...(mode ? { mode } : {}) }
}
const commandArgs = args.slice(1)
if (command === 'config') {
expectNoArguments(command, commandArgs)
return { kind: 'config' }
}
if (command === 'add') {
const feature = commandArgs[0]
if (!feature) {
if (helpRequested) return { kind: 'add', feature: '', args: [] }
fail('add requires a feature')
}
if (feature.startsWith('-')) fail(`Unknown add option: ${feature}`)
const featureArgs = commandArgs.slice(1)
if (feature === 'integration') {
if (featureArgs.length === 0 && helpRequested) {
return { kind: 'add', feature, args: [] }
}
if (featureArgs.length !== 1 || featureArgs[0].startsWith('-')) {
fail('add integration requires exactly one integration slug')
}
} else if (featureArgs.length > 0) {
fail(`add ${feature} does not accept: ${featureArgs.join(' ')}`)
}
return { kind: 'add', feature, args: featureArgs }
}
if (command === 'doctor') {
const fix = oneFlag(commandArgs, '--fix')
const json = oneFlag(commandArgs, '--json')
const remaining = commandArgs.filter((arg) => arg !== '--fix' && arg !== '--json')
if (remaining.length > 0) fail(`Unknown doctor option: ${remaining[0]}`)
return { kind: 'doctor', fix, json }
}
if (isLifecycleCommand(command)) {
expectNoArguments(command, commandArgs)
return { kind: 'lifecycle', command }
}
fail(`Unknown command: ${command}`)
}
/** Parses and validates the complete public command surface before any setup work begins. */
export function parseSetupArguments(rawArgs: readonly string[]): SetupInvocation {
const args = stripDirectoryOption(rawArgs)
const helpCount = args.filter((arg) => arg === '--help' || arg === '-h').length
if (helpCount > 1) fail('--help may only be provided once')
const versionCount = args.filter((arg) => arg === '--version' || arg === '-V').length
if (versionCount > 1) fail('--version may only be provided once')
if (helpCount > 0 && versionCount > 0) fail('--help and --version cannot be combined')
if (versionCount === 1) {
const remaining = args.filter((arg) => arg !== '--version' && arg !== '-V')
expectNoArguments('--version', remaining)
return { kind: 'version' }
}
const helpRequested = helpCount === 1
const withoutHelp = args.filter((arg) => arg !== '--help' && arg !== '-h')
const invocation = parseCore(withoutHelp, helpRequested)
return helpRequested ? { kind: 'help' } : invocation
}
@@ -0,0 +1,40 @@
import {
chmodSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { writeTextFileAtomic } from './atomic-file'
const roots: string[] = []
function temporaryRoot(): string {
const root = mkdtempSync(path.join(tmpdir(), 'sim-setup-atomic-file-'))
roots.push(root)
return root
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('writeTextFileAtomic', () => {
it('replaces the complete file with the requested permissions and no staged residue', () => {
const root = temporaryRoot()
const file = path.join(root, '.env')
writeFileSync(file, 'OLD=value\n')
chmodSync(file, 0o644)
writeTextFileAtomic(file, 'SECRET=current\n', { mode: 0o600 })
expect(readFileSync(file, 'utf8')).toBe('SECRET=current\n')
expect(statSync(file).mode & 0o777).toBe(0o600)
expect(readdirSync(root)).toEqual(['.env'])
})
})
+32
View File
@@ -0,0 +1,32 @@
import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { generateShortId } from '@sim/utils/id'
interface AtomicWriteOptions {
mode: number
}
/** Writes a complete replacement beside its target, then publishes it with one rename. */
export function writeTextFileAtomic(
filePath: string,
contents: string,
options: AtomicWriteOptions
): void {
const directory = path.dirname(filePath)
mkdirSync(directory, { recursive: true })
const temporaryPath = path.join(
directory,
`.${path.basename(filePath)}.${process.pid}.${generateShortId(10)}.tmp`
)
try {
writeFileSync(temporaryPath, contents, {
encoding: 'utf8',
flag: 'wx',
mode: options.mode,
})
renameSync(temporaryPath, filePath)
} finally {
if (existsSync(temporaryPath)) unlinkSync(temporaryPath)
}
}
@@ -1,7 +1,7 @@
import { sleep } from '@sim/utils/helpers'
import chalk from 'chalk'
import { restoreTerminal } from './terminal.ts'
import { isRich, theme } from './theme.ts'
import { restoreTerminal } from './terminal'
import { isRich, theme } from './theme'
const WORDMARK = [' ▀ ', '▄▀▀▀ █ █▀█▀█', '▀▀▀▄ █ █ █ █', '▄▄▄▀ █ █ █ █'] as const
const TAGLINE = 'the AI workspace'
+18
View File
@@ -0,0 +1,18 @@
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const repositoryRoot = path.resolve(packageRoot, '../..')
const dist = path.join(packageRoot, 'dist')
mkdirSync(dist, { recursive: true })
const compose = readFileSync(path.join(repositoryRoot, 'docker-compose.prod.yml'), 'utf8')
if (/^\s+(?:build|context):/m.test(compose)) {
throw new Error('docker-compose.prod.yml must not contain local build dependencies')
}
if (!compose.includes('ghcr.io/simstudioai/simstudio')) {
throw new Error('docker-compose.prod.yml is missing the published Sim image')
}
writeFileSync(path.join(dist, 'docker-compose.prod.yml'), compose)
chmodSync(path.join(dist, 'index.js'), 0o755)
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'bun:test'
import {
defineCapability,
ENV_CAPABILITIES,
envField,
OAUTH_CLIENT_CAPABILITIES,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
} from '@sim/deployment-config/env-capabilities'
import { describe, expect, it } from 'vitest'
import {
CAPABILITY_SETUPS,
defineCapabilitySetup,
@@ -12,8 +12,8 @@ import {
getOAuthClientSetupFields,
KNOWLEDGE_EMBEDDINGS_SETUP,
STORAGE_SETUP,
} from './capability-config.ts'
import { getCapabilitySetupOptions } from './capability-setup.ts'
} from './capability-config'
import { getCapabilitySetupOptions } from './capability-setup'
describe('capability setup configuration', () => {
it('maps every runtime capability and provider exactly once', () => {
@@ -20,7 +20,7 @@ import {
OCR_CAPABILITY,
SANDBOX_CAPABILITY,
STORAGE_CAPABILITY,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
} from '@sim/deployment-config/env-capabilities'
export type SetupHint =
| string
@@ -863,7 +863,7 @@ export function getCapabilitySetup(id: string): CapabilitySetupDefinition | null
}
export function getSetupCommand(id: string): string {
return `bun run setup ${id}`
return `npx @sim/setup add ${id}`
}
type OAuthClientSetupFields = {
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test'
import type { SetupFieldPrompt } from './capability-config.ts'
import { formatCapabilitySetupFieldMessage, markCurrentlyUsed } from './capability-setup.ts'
import { describe, expect, it } from 'vitest'
import type { SetupFieldPrompt } from './capability-config'
import { formatCapabilitySetupFieldMessage, markCurrentlyUsed } from './capability-setup'
describe('capability setup presentation', () => {
it('marks the effective option as currently used without replacing its hint', () => {
@@ -12,15 +12,15 @@ import {
inspectCapability,
isTruthyEnvCapabilityValue,
validateCapabilityFieldInput,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
} from '@sim/deployment-config/env-capabilities'
import {
type CapabilitySetupDefinition,
matchesSetupCondition,
type SetupCondition,
type SetupHint,
type SetupPrompt,
} from './capability-config.ts'
import * as p from './prompter.ts'
} from './capability-config'
import * as p from './prompter'
export interface CapabilitySetupContext {
containerized: boolean
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test'
import { OAUTH_CLIENT_CAPABILITIES } from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { buildEnvCapabilityStatus } from './capability-status.ts'
import { OAUTH_CLIENT_CAPABILITIES } from '@sim/deployment-config/env-capabilities'
import { describe, expect, it } from 'vitest'
import { buildEnvCapabilityStatus } from './capability-status'
const DAYTONA_FUNCTION_SNAPSHOT_ID = '00000000-0000-4000-8000-000000000002'
@@ -38,7 +38,7 @@ describe('env capability status', () => {
expect(status.features.sandbox).toEqual({
id: 'sandbox',
label: 'Function sandboxes',
setupCommand: 'bun run setup sandbox',
setupCommand: 'npx @sim/setup add sandbox',
state: 'default',
providerId: 'disabled',
})
@@ -24,8 +24,8 @@ import {
type ProviderInspection,
SANDBOX_CAPABILITY,
STORAGE_CAPABILITY,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { SETUP_FEATURES, type SetupFeatureId } from './capability-config.ts'
} from '@sim/deployment-config/env-capabilities'
import { SETUP_FEATURES, type SetupFeatureId } from './capability-config'
export type SetupStatusFeatureId = Exclude<SetupFeatureId, 'integration'>
export type CapabilityStatusState = 'default' | 'configured' | 'missing' | 'partial' | 'invalid'
@@ -38,7 +38,7 @@ export interface CapabilityStatusIssue {
interface FeatureStatusBase<TId extends SetupStatusFeatureId> {
id: TId
label: string
setupCommand: `bun run setup ${TId}`
setupCommand: `npx @sim/setup add ${TId}`
state: CapabilityStatusState
issue?: CapabilityStatusIssue
}
@@ -143,7 +143,7 @@ function featureMetadata<TId extends SetupStatusFeatureId>(id: TId) {
return {
id,
label: definition.label,
setupCommand: `bun run setup ${id}` as const,
setupCommand: `npx @sim/setup add ${id}` as const,
}
}
@@ -11,9 +11,9 @@ import {
requireCapability,
SANDBOX_CAPABILITY,
STORAGE_CAPABILITY,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { getSetupCommand } from './capability-config.ts'
import { portOpen } from './detect.ts'
} from '@sim/deployment-config/env-capabilities'
import { getSetupCommand } from './capability-config'
import { portOpen } from './detect'
import {
type EnvFile,
type EnvTarget,
@@ -26,9 +26,9 @@ import {
SHARED_KEYS,
secretRequirement,
writeEnvValues,
} from './env-files.ts'
import { httpHealth, pgProbe, redisPing } from './probes.ts'
import { FLAG_TWINS, LOGIN_PROVIDERS } from './twins.ts'
} from './env-files'
import { httpHealth, pgProbe, redisPing } from './probes'
import { FLAG_TWINS, LOGIN_PROVIDERS } from './twins'
export type CheckGroup = 'files' | 'schema' | 'consistency' | 'coherence' | 'live'
export type CheckStatus = 'pass' | 'warn' | 'fail' | 'skip'
@@ -113,7 +113,7 @@ function checkFiles(ctx: CheckContext): Finding[] {
group: 'files',
status: 'fail',
message: 'no env files found',
fix: 'run: bun run setup',
fix: 'run: npx @sim/setup',
},
]
}
@@ -135,7 +135,7 @@ function checkFiles(ctx: CheckContext): Finding[] {
message: `${rel(file)} is missing`,
fix: canSeed
? `run doctor --fix to seed it from apps/${target === 'db' ? '../packages/db' : target}/.env.example + apps/sim/.env`
: 'run: bun run setup',
: 'run: npx @sim/setup',
autofix: canSeed
? () => {
const keys = target === 'db' ? ['DATABASE_URL'] : [...SHARED_KEYS]
@@ -559,7 +559,7 @@ async function checkDatabase(sim: EnvFile): Promise<Finding[]> {
group: 'live',
status: 'fail',
message: `database unreachable: ${probe.error}`,
fix: 'start Postgres (bun run setup can manage a pgvector container) or fix DATABASE_URL',
fix: 'start Postgres (npx @sim/setup can manage a pgvector container) or fix DATABASE_URL',
})
} else {
findings.push({
@@ -577,7 +577,7 @@ async function checkDatabase(sim: EnvFile): Promise<Finding[]> {
}
const { applied, journal } = probe.migrations ?? {
applied: null,
journal: 0,
journal: null,
}
if (applied === null) {
findings.push({
@@ -586,7 +586,7 @@ async function checkDatabase(sim: EnvFile): Promise<Finding[]> {
message: 'migrations have never run on this database',
fix: 'cd packages/db && bun run db:migrate',
})
} else if (applied < journal) {
} else if (journal !== null && applied < journal) {
findings.push({
group: 'live',
status: 'warn',
@@ -597,7 +597,10 @@ async function checkDatabase(sim: EnvFile): Promise<Finding[]> {
findings.push({
group: 'live',
status: 'pass',
message: `migrations up to date (${applied})`,
message:
journal === null
? `database has ${applied} migrations applied`
: `migrations up to date (${applied})`,
})
}
}
+132
View File
@@ -0,0 +1,132 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { buildApprovalUrl, normalizeAuthOrigin, pollOnce } from './cli-auth'
import { SETUP_USER_AGENT } from './version'
const ORIGIN = 'https://www.sim.test/prefix'
const REQUEST = 'request'
const VERIFIER = 'verifier'
function jsonResponse(status: number, body: unknown, headers?: Record<string, string>): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', ...headers },
})
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('normalizeAuthOrigin', () => {
it('keeps a path prefix and removes only trailing slashes', () => {
expect(normalizeAuthOrigin('https://www.sim.test/prefix///')).toBe(ORIGIN)
})
it('rejects malformed and ambiguous service roots', () => {
expect(() => normalizeAuthOrigin('not-a-url')).toThrow('absolute HTTP(S) URL')
expect(() => normalizeAuthOrigin('ftp://sim.test')).toThrow('expected HTTP or HTTPS')
expect(() => normalizeAuthOrigin('https://sim.test?target=other')).toThrow(
'cannot contain credentials, a query, or a fragment'
)
})
})
describe('buildApprovalUrl', () => {
it('preserves the origin prefix and never includes the poll secret', () => {
const url = buildApprovalUrl(ORIGIN, REQUEST, 'challenge', 'ABCD-2345')
expect(url).toMatch(/^https:\/\/www\.sim\.test\/prefix\/cli\/auth\?/)
expect(url).toContain('challenge=challenge')
expect(url).not.toContain(VERIFIER)
})
})
describe('pollOnce', () => {
it('bounds and identifies the request without following redirects', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(jsonResponse(200, { status: 'complete', key: { apiKey: 'key' } }))
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER)).resolves.toEqual({
status: 'complete',
apiKey: 'key',
})
expect(fetchMock.mock.calls[0][0]).toBe(`${ORIGIN}/api/cli/auth/poll`)
expect(fetchMock.mock.calls[0][1]).toMatchObject({
method: 'POST',
redirect: 'manual',
headers: expect.objectContaining({
accept: 'application/json',
'user-agent': SETUP_USER_AGENT,
}),
signal: expect.any(AbortSignal),
})
})
it('keeps pending, rate-limited, server, and transport failures retryable', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(200, { status: 'pending' }))
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER)).resolves.toEqual({ status: 'pending' })
fetchMock.mockResolvedValueOnce(
jsonResponse(429, { error: 'slow down' }, { 'retry-after': '4' })
)
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER)).resolves.toEqual({
status: 'pending',
retryAfterMs: 4000,
})
fetchMock.mockResolvedValueOnce(jsonResponse(503, { error: 'deploying' }))
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER)).resolves.toEqual({ status: 'pending' })
fetchMock.mockResolvedValueOnce(jsonResponse(409, { error: 'name collision' }))
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER)).resolves.toEqual({ status: 'pending' })
fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'))
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER)).resolves.toEqual({ status: 'pending' })
fetchMock.mockRejectedValueOnce(new DOMException('timed out', 'TimeoutError'))
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER)).resolves.toEqual({ status: 'pending' })
})
it('fails immediately on deliberate refusals and unexpected transport errors', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(jsonResponse(403, { error: { message: 'Forbidden' } }))
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER)).rejects.toThrow('Forbidden')
fetchMock.mockRejectedValueOnce(new Error('programming failure'))
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER)).rejects.toThrow('programming failure')
})
it('refuses redirects instead of forwarding the poll secret', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(null, {
status: 301,
headers: { location: 'https://other.sim.test/api/cli/auth/poll' },
})
)
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER)).rejects.toThrow(
'will not forward the poll secret across a redirect'
)
})
it('rejects non-JSON and malformed successful responses', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
fetchMock.mockResolvedValueOnce(new Response('<html>wrong host</html>', { status: 200 }))
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER)).rejects.toThrow('non-JSON response')
fetchMock.mockResolvedValueOnce(jsonResponse(200, { status: 'complete' }))
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER)).rejects.toThrow(
'completed without a valid API key'
)
})
it('rejects an invalid request timeout before calling fetch', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
await expect(pollOnce(ORIGIN, REQUEST, VERIFIER, 0)).rejects.toThrow(
'Poll timeout must be a positive whole number'
)
expect(fetchMock).not.toHaveBeenCalled()
})
})
+247
View File
@@ -0,0 +1,247 @@
import { spawnSync } from 'node:child_process'
import { sha256Base64Url } from '@sim/security/hash'
import { generateSecureToken } from '@sim/security/tokens'
import { sleep } from '@sim/utils/helpers'
import { generateShortId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { parseRetryAfter } from '@sim/utils/retry'
import { truncate } from '@sim/utils/string'
import * as p from './prompter'
import { link, theme } from './theme'
import { SETUP_USER_AGENT } from './version'
const WAIT_MS = 900_000
const POLL_INTERVAL_MS = 2000
const POLL_REQUEST_TIMEOUT_MS = 15_000
const APPROVAL_PATH = '/cli/auth'
const POLL_PATH = '/api/cli/auth/poll'
const RETRYABLE_POLL_STATUSES = new Set([409, 429, 500, 502, 503, 504])
const PAIRING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
export type AuthPollResult =
| { status: 'pending'; retryAfterMs?: number }
| { status: 'complete'; apiKey: string }
/** Opens a safely encoded URL across platforms, including Windows' cmd-backed `start`. */
function openBrowser(url: string): void {
if (process.env.SIM_SETUP_NO_BROWSER) return
if (process.platform === 'win32') {
spawnSync('cmd', ['/c', 'start', '""', `"${url}"`], {
stdio: 'ignore',
windowsVerbatimArguments: true,
})
return
}
const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
spawnSync(command, [url], { stdio: 'ignore' })
}
/** Short human-comparable code with no look-alike characters. */
function createPairingCode(): string {
const chars = generateShortId(8, PAIRING_ALPHABET)
return `${chars.slice(0, 4)}-${chars.slice(4)}`
}
/** Validates the auth service root and preserves any path prefix it carries. */
export function normalizeAuthOrigin(origin: string): string {
const value = origin.trim()
let parsed: URL
try {
parsed = new URL(value)
} catch {
throw new Error(
`Invalid Chat authorization origin "${origin}"; expected an absolute HTTP(S) URL.`
)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(
`Unsupported Chat authorization origin scheme "${parsed.protocol.replace(/:$/, '')}"; expected HTTP or HTTPS.`
)
}
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error('Chat authorization origin cannot contain credentials, a query, or a fragment.')
}
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${parsed.origin}${prefix}`
}
function authUrl(origin: string, path: string): string {
return `${normalizeAuthOrigin(origin)}${path}`
}
/** Builds the browser URL without ever including the redeemable poll secret. */
export function buildApprovalUrl(
origin: string,
request: string,
challenge: string,
pairing: string
): string {
const query = new URLSearchParams({ request, challenge, pairing })
return `${authUrl(origin, APPROVAL_PATH)}?${query}`
}
function isRetryableTransportError(error: unknown): boolean {
if (error instanceof TypeError) return true
return (
typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'TimeoutError'
)
}
async function responseError(response: Response): Promise<string> {
const fallback = `Chat authorization failed with HTTP ${response.status}`
const raw = await response.text()
if (!raw) return fallback
let body: unknown
try {
body = JSON.parse(raw)
} catch {
return fallback
}
if (!isRecordLike(body)) return fallback
if (typeof body.error === 'string' && body.error) return truncate(body.error, 500)
if (isRecordLike(body.error) && typeof body.error.message === 'string' && body.error.message) {
return truncate(body.error.message, 500)
}
if (typeof body.message === 'string' && body.message) return truncate(body.message, 500)
return fallback
}
function redirectError(origin: string, response: Response): Error {
const location = response.headers.get('location')?.trim()
if (!location) {
return new Error(
`Chat authorization origin ${normalizeAuthOrigin(origin)} returned HTTP ${response.status} without a redirect target.`
)
}
let target: URL
try {
target = new URL(location, `${normalizeAuthOrigin(origin)}/`)
} catch {
return new Error(
`Chat authorization origin ${normalizeAuthOrigin(origin)} returned HTTP ${response.status} with an invalid redirect target.`
)
}
return new Error(
`Chat authorization origin redirected the key poll to ${target.href}. Set SIM_CLI_AUTH_ORIGIN to the final service URL; setup will not forward the poll secret across a redirect.`
)
}
function parsePollResponse(raw: string): AuthPollResult {
let body: unknown
try {
body = JSON.parse(raw)
} catch {
throw new Error('Chat authorization service returned a non-JSON response.')
}
if (!isRecordLike(body) || (body.status !== 'pending' && body.status !== 'complete')) {
throw new Error('Chat authorization service returned an invalid poll response.')
}
if (body.status === 'pending') return { status: 'pending' }
if (!isRecordLike(body.key) || typeof body.key.apiKey !== 'string' || !body.key.apiKey) {
throw new Error('Chat authorization completed without a valid API key.')
}
return { status: 'complete', apiKey: body.key.apiKey }
}
/** Performs one bounded poll, retrying only known transient transport and service failures. */
export async function pollOnce(
origin: string,
request: string,
verifier: string,
timeoutMs: number = POLL_REQUEST_TIMEOUT_MS
): Promise<AuthPollResult> {
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
throw new Error(
`Poll timeout must be a positive whole number of milliseconds, got ${timeoutMs}.`
)
}
let response: Response
try {
response = await fetch(authUrl(origin, POLL_PATH), {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
'user-agent': SETUP_USER_AGENT,
},
body: JSON.stringify({ request, verifier }),
signal: AbortSignal.timeout(timeoutMs),
redirect: 'manual',
})
} catch (error) {
if (isRetryableTransportError(error)) return { status: 'pending' }
throw error
}
if (response.status >= 300 && response.status <= 399) throw redirectError(origin, response)
if (RETRYABLE_POLL_STATUSES.has(response.status)) {
const retryAfterMs =
response.status === 429 ? parseRetryAfter(response.headers.get('retry-after')) : null
return {
status: 'pending',
...(retryAfterMs && retryAfterMs > 0 ? { retryAfterMs } : {}),
}
}
if (!response.ok) throw new Error(await responseError(response))
return parsePollResponse(await response.text())
}
/**
* Opens the approval page and polls for the Chat key without a loopback listener.
*
* The poll secret never enters the browser URL. Fatal protocol and authorization
* responses stop immediately; only known transient failures remain pending.
*/
export async function browserKeyFlow(origin: string): Promise<string | null> {
const normalizedOrigin = normalizeAuthOrigin(origin)
const request = generateSecureToken(32)
const pollSecret = generateSecureToken(32)
const challenge = sha256Base64Url(pollSecret)
const pairingCode = createPairingCode()
const approvalUrl = buildApprovalUrl(normalizedOrigin, request, challenge, pairingCode)
p.note(
`${theme.heading(pairingCode)}\n\n${theme.muted('The page should show this code. If it shows a different one,\nthe request is not from this terminal — close the tab.')}`,
'Confirm this code in your browser'
)
p.log.info(
`Opening your browser — create your account (or sign in) and approve; the key comes back automatically.\n If it doesn't open: ${link(approvalUrl, approvalUrl)}`
)
openBrowser(approvalUrl)
const spin = p.spinner()
spin.start('Waiting for approval in your browser')
const deadline = Date.now() + WAIT_MS
let delayMs = POLL_INTERVAL_MS
try {
while (Date.now() < deadline) {
const beforePollMs = deadline - Date.now()
await sleep(Math.min(delayMs, beforePollMs))
const remainingMs = deadline - Date.now()
if (remainingMs <= 0) break
const result = await pollOnce(
normalizedOrigin,
request,
pollSecret,
Math.min(POLL_REQUEST_TIMEOUT_MS, remainingMs)
)
if (result.status === 'complete') {
spin.stop('Approved')
return result.apiKey
}
delayMs = result.retryAfterMs ?? POLL_INTERVAL_MS
}
} catch (error) {
spin.stop('Browser handoff failed')
throw error
}
spin.stop('Browser handoff timed out')
return null
}
@@ -0,0 +1,73 @@
import { createHash } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { ensureProductionComposeFile } from './compose-asset'
const roots: string[] = []
function tempRoot(): string {
const root = mkdtempSync(path.join(tmpdir(), 'sim-setup-compose-'))
roots.push(root)
return root
}
function standaloneContext(root: string) {
return { kind: 'standalone', root, existing: false } as const
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('ensureProductionComposeFile', () => {
it('materializes the packaged production Compose file and managed state', () => {
const root = tempRoot()
const composeFile = ensureProductionComposeFile(standaloneContext(root))
expect(readFileSync(composeFile, 'utf8')).toContain('ghcr.io/simstudioai/simstudio')
expect(JSON.parse(readFileSync(path.join(root, '.sim-setup.json'), 'utf8'))).toMatchObject({
schemaVersion: 1,
composeSha256: expect.stringMatching(/^[0-9a-f]{64}$/),
})
})
it('updates a previously managed Compose file', () => {
const root = tempRoot()
const composeFile = ensureProductionComposeFile(standaloneContext(root))
const previous = `${readFileSync(composeFile, 'utf8')}\nservices: {}\n`
writeFileSync(composeFile, previous)
writeFileSync(
path.join(root, '.sim-setup.json'),
JSON.stringify({
schemaVersion: 1,
composeSha256: createHash('sha256').update(previous).digest('hex'),
})
)
ensureProductionComposeFile({ kind: 'standalone', root, existing: true })
expect(readFileSync(composeFile, 'utf8')).not.toBe(previous)
})
it('fails instead of overwriting local Compose changes', () => {
const root = tempRoot()
const composeFile = ensureProductionComposeFile(standaloneContext(root))
writeFileSync(composeFile, `${readFileSync(composeFile, 'utf8')}\n# local change\n`)
expect(() => ensureProductionComposeFile({ kind: 'standalone', root, existing: true })).toThrow(
'has local changes'
)
})
it('fails on an unrelated Compose file', () => {
const root = tempRoot()
writeFileSync(path.join(root, 'docker-compose.prod.yml'), 'services: {}\n')
expect(() => ensureProductionComposeFile(standaloneContext(root))).toThrow(
'is not a recognized Sim Compose file'
)
})
})
+84
View File
@@ -0,0 +1,84 @@
import { createHash } from 'node:crypto'
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { SETUP_CONTEXT, type SetupContext } from './context'
const COMPOSE_FILE = 'docker-compose.prod.yml'
const SIM_COMPOSE_MARKER = 'ghcr.io/simstudioai/simstudio'
const MANAGED_STATE_FILE = '.sim-setup.json'
interface ManagedState {
schemaVersion: 1
composeSha256: string
}
function sha256(contents: string): string {
return createHash('sha256').update(contents).digest('hex')
}
function readManagedState(root: string): ManagedState | null {
const file = path.join(root, MANAGED_STATE_FILE)
if (!existsSync(file)) return null
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
if (
typeof parsed !== 'object' ||
parsed === null ||
!('schemaVersion' in parsed) ||
parsed.schemaVersion !== 1 ||
!('composeSha256' in parsed) ||
typeof parsed.composeSha256 !== 'string'
) {
throw new Error(`${file} is not a valid @sim/setup managed-state file`)
}
return { schemaVersion: 1, composeSha256: parsed.composeSha256 }
}
function writeManagedState(root: string, contents: string): void {
const state: ManagedState = { schemaVersion: 1, composeSha256: sha256(contents) }
writeFileSync(path.join(root, MANAGED_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`)
}
function packagedComposeFile(): string {
const moduleDirectory = path.dirname(fileURLToPath(import.meta.url))
return path.basename(moduleDirectory) === 'src'
? path.resolve(moduleDirectory, '../../..', COMPOSE_FILE)
: path.join(moduleDirectory, COMPOSE_FILE)
}
/** Materializes the published Compose asset without overwriting user-customized files. */
export function ensureProductionComposeFile(context: SetupContext = SETUP_CONTEXT): string {
const destination = path.join(context.root, COMPOSE_FILE)
if (context.kind === 'source') {
if (!existsSync(destination)) {
throw new Error(`Sim source checkout is missing ${COMPOSE_FILE}`)
}
return destination
}
const bundled = packagedComposeFile()
if (!existsSync(bundled)) {
throw new Error(`The @sim/setup package is missing its bundled ${COMPOSE_FILE}`)
}
mkdirSync(context.root, { recursive: true })
const packaged = readFileSync(bundled, 'utf8')
if (existsSync(destination)) {
const current = readFileSync(destination, 'utf8')
if (current === packaged) {
writeManagedState(context.root, packaged)
return destination
}
if (!current.includes(SIM_COMPOSE_MARKER)) {
throw new Error(`${destination} exists but is not a recognized Sim Compose file`)
}
const managed = readManagedState(context.root)
if (!managed || managed.composeSha256 !== sha256(current)) {
throw new Error(
`${destination} has local changes; preserve or remove your customizations before updating it.`
)
}
}
copyFileSync(bundled, destination)
writeManagedState(context.root, packaged)
return destination
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { legacyComposeProjectName, standaloneComposeProjectName } from './compose-project'
describe('standaloneComposeProjectName', () => {
it('is stable for one installation directory', () => {
expect(standaloneComposeProjectName('/srv/one/sim')).toBe(
standaloneComposeProjectName('/srv/one/sim')
)
})
it('isolates installations that share the same directory basename', () => {
expect(standaloneComposeProjectName('/srv/one/sim')).not.toBe(
standaloneComposeProjectName('/srv/two/sim')
)
})
it('produces a valid Compose project name without exposing the path', () => {
expect(standaloneComposeProjectName('/Users/example/Customer Project/sim')).toMatch(
/^sim-[0-9a-f]{12}$/
)
})
it('retains the directory-derived name for legacy installations', () => {
expect(legacyComposeProjectName('/srv/Sim.Demo')).toBe('simdemo')
expect(() => legacyComposeProjectName('/srv/---')).toThrow(/Cannot derive/)
})
})
+22
View File
@@ -0,0 +1,22 @@
import { createHash } from 'node:crypto'
import path from 'node:path'
/** Returns the stable Docker Compose project name for a new standalone installation. */
export function standaloneComposeProjectName(root: string): string {
const digest = createHash('sha256').update(path.resolve(root)).digest('hex').slice(0, 12)
return `sim-${digest}`
}
/** Reproduces Compose's directory-derived identity for installs created before names were stored. */
export function legacyComposeProjectName(root: string): string {
const name = path
.basename(path.resolve(root))
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '')
if (!/^[a-z0-9]/.test(name)) {
throw new Error(
`Cannot derive the existing Compose project name from ${root}; set COMPOSE_PROJECT_NAME in its .env file.`
)
}
return name
}
@@ -1,14 +1,14 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'bun:test'
import { afterEach, describe, expect, it } from 'vitest'
import {
type ConfigurationCommandRunner,
type ConfigurationSourceDiscoveryOptions,
discoverConfigurationSources as discoverConfigurationSourcesFromEnvironment,
parseComposeFileEnvironment,
resolveKubernetesContainerEnvironment,
} from './configuration-sources.ts'
} from './configuration-sources'
const temporaryDirectories: string[] = []
@@ -5,8 +5,8 @@ import { loadEnvConfig } from '@next/env'
import {
CORE_CONFIGURATION_KEYS,
DEPLOYMENT_CONFIGURATION_KEYS,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { isPlaceholder, parseEnv, ROOT, SHARED_KEYS } from './env-files.ts'
} from '@sim/deployment-config/env-capabilities'
import { isPlaceholder, parseEnv, ROOT, SHARED_KEYS } from './env-files'
export type ConfigurationSourceKind = 'dev' | 'compose' | 'helm'
+112
View File
@@ -0,0 +1,112 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveSetupContext } from './context'
const roots: string[] = []
function tempRoot(): string {
const root = mkdtempSync(path.join(tmpdir(), 'sim-setup-context-'))
roots.push(root)
return root
}
function writePackage(root: string, relativePath: string, name: string): void {
const file = path.join(root, relativePath)
mkdirSync(path.dirname(file), { recursive: true })
writeFileSync(file, JSON.stringify({ name }))
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('resolveSetupContext', () => {
it('finds a Sim checkout from a nested directory', () => {
const root = tempRoot()
writePackage(root, 'package.json', 'simstudio')
writePackage(root, 'apps/sim/package.json', '@sim/app')
writePackage(root, 'apps/realtime/package.json', '@sim/realtime')
writePackage(root, 'packages/db/package.json', '@sim/db')
const nested = path.join(root, 'apps/sim/lib')
mkdirSync(nested, { recursive: true })
expect(resolveSetupContext(nested, [])).toEqual({ kind: 'source', root })
})
it('finds an existing standalone installation', () => {
const root = tempRoot()
writeFileSync(
path.join(root, 'docker-compose.prod.yml'),
'image: ghcr.io/simstudioai/simstudio:latest\n'
)
expect(resolveSetupContext(root, [])).toEqual({ kind: 'standalone', root, existing: true })
})
it('creates a dedicated child directory outside an installation', () => {
const root = tempRoot()
expect(resolveSetupContext(root, [])).toEqual({
kind: 'standalone',
root: path.join(root, 'sim'),
existing: false,
})
})
it('finds an existing installation in the default child directory', () => {
const root = tempRoot()
const installRoot = path.join(root, 'sim')
mkdirSync(installRoot)
writeFileSync(
path.join(installRoot, 'docker-compose.prod.yml'),
'image: ghcr.io/simstudioai/simstudio:latest\n'
)
expect(resolveSetupContext(root, [])).toEqual({
kind: 'standalone',
root: installRoot,
existing: true,
})
})
it('fails on a partial Sim checkout', () => {
const root = tempRoot()
writePackage(root, 'package.json', 'simstudio')
writePackage(root, 'apps/sim/package.json', '@sim/app')
expect(() => resolveSetupContext(root, [])).toThrow('Incomplete Sim source checkout')
})
it('fails on malformed package metadata instead of ignoring it', () => {
const root = tempRoot()
writeFileSync(path.join(root, 'package.json'), '{')
expect(() => resolveSetupContext(root, [])).toThrow(SyntaxError)
})
it('honors an explicit installation directory', () => {
const root = tempRoot()
expect(resolveSetupContext(root, ['--dir', 'custom'])).toEqual({
kind: 'standalone',
root: path.join(root, 'custom'),
existing: false,
})
})
it('does not replace an explicit standalone directory with an ancestor checkout', () => {
const root = tempRoot()
writePackage(root, 'package.json', 'simstudio')
writePackage(root, 'apps/sim/package.json', '@sim/app')
writePackage(root, 'apps/realtime/package.json', '@sim/realtime')
writePackage(root, 'packages/db/package.json', '@sim/db')
expect(resolveSetupContext(root, ['--dir', 'deployment'])).toEqual({
kind: 'standalone',
root: path.join(root, 'deployment'),
existing: false,
})
})
})
+118
View File
@@ -0,0 +1,118 @@
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
const SOURCE_PACKAGE_MARKERS = [
['apps/sim/package.json', '@sim/app'],
['apps/realtime/package.json', '@sim/realtime'],
['packages/db/package.json', '@sim/db'],
] as const
const SIM_COMPOSE_MARKER = 'ghcr.io/simstudioai/simstudio'
const COMPOSE_FILE = 'docker-compose.prod.yml'
export type SetupContext =
| { kind: 'source'; root: string }
| { kind: 'standalone'; root: string; existing: boolean }
function readPackageName(file: string): string | null {
if (!existsSync(file)) return null
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
if (typeof parsed !== 'object' || parsed === null || !('name' in parsed)) return null
return typeof parsed.name === 'string' ? parsed.name : null
}
function parentDirectories(start: string): string[] {
const directories: string[] = []
let current = path.resolve(start)
while (true) {
directories.push(current)
const parent = path.dirname(current)
if (parent === current) return directories
current = parent
}
}
function inspectSourceRoot(candidate: string): 'valid' | 'partial' | 'absent' {
const rootName = readPackageName(path.join(candidate, 'package.json'))
const markerNames = SOURCE_PACKAGE_MARKERS.map(([file]) =>
readPackageName(path.join(candidate, file))
)
const looksLikeSource = rootName === 'simstudio' || markerNames.some((name) => name !== null)
if (!looksLikeSource) return 'absent'
if (
rootName === 'simstudio' &&
SOURCE_PACKAGE_MARKERS.every(([, expected], index) => markerNames[index] === expected)
) {
return 'valid'
}
return 'partial'
}
function isStandaloneInstall(candidate: string): boolean {
const composeFile = path.join(candidate, COMPOSE_FILE)
if (!existsSync(composeFile)) return false
return readFileSync(composeFile, 'utf8').includes(SIM_COMPOSE_MARKER)
}
export function directoryOverride(args: readonly string[]): string | null {
const equalsArg = args.find((arg) => arg.startsWith('--dir='))
if (equalsArg) {
const value = equalsArg.slice('--dir='.length)
if (!value) throw new Error('--dir requires a directory path')
return value
}
const index = args.indexOf('--dir')
if (index === -1) return null
const value = args[index + 1]
if (!value || value.startsWith('-')) throw new Error('--dir requires a directory path')
return value
}
/** Classifies one exact filesystem root without walking through its ancestors. */
export function resolveSetupContextAtRoot(root: string): SetupContext {
const resolvedRoot = path.resolve(root)
const source = inspectSourceRoot(resolvedRoot)
if (source === 'valid') return { kind: 'source', root: resolvedRoot }
if (source === 'partial') {
throw new Error(
`Incomplete Sim source checkout at ${resolvedRoot}; expected package.json plus apps/sim, apps/realtime, and packages/db package manifests.`
)
}
return {
kind: 'standalone',
root: resolvedRoot,
existing: isStandaloneInstall(resolvedRoot),
}
}
/** Resolves the filesystem context before setup reads or writes any installation state. */
export function resolveSetupContext(
start: string = process.cwd(),
args: readonly string[] = process.argv.slice(2)
): SetupContext {
const override = directoryOverride(args)
const searchStart = path.resolve(start, override ?? '.')
if (override) {
return resolveSetupContextAtRoot(searchStart)
}
for (const candidate of parentDirectories(searchStart)) {
const source = inspectSourceRoot(candidate)
if (source === 'valid') return { kind: 'source', root: candidate }
if (source === 'partial') {
throw new Error(
`Incomplete Sim source checkout at ${candidate}; expected package.json plus apps/sim, apps/realtime, and packages/db package manifests.`
)
}
}
for (const candidate of parentDirectories(searchStart)) {
if (isStandaloneInstall(candidate)) {
return { kind: 'standalone', root: candidate, existing: true }
}
}
return resolveSetupContextAtRoot(path.join(path.resolve(start), 'sim'))
}
export const SETUP_CONTEXT = resolveSetupContext()
@@ -1,11 +1,11 @@
import { spawnSync } from 'node:child_process'
import { DB_CONTAINER, type Detection } from './detect.ts'
import { ensureDocker } from './docker.ts'
import { generateSecret } from './env-files.ts'
import { SetupError } from './errors.ts'
import { pgProbe, waitFor } from './probes.ts'
import * as p from './prompter.ts'
import { glyph, theme } from './theme.ts'
import { DB_CONTAINER, type Detection } from './detect'
import { ensureDocker } from './docker'
import { generateSecret } from './env-files'
import { SetupError } from './errors'
import { pgProbe, waitFor } from './probes'
import * as p from './prompter'
import { glyph, theme } from './theme'
const DEFAULT_DSN = 'postgresql://postgres:postgres@localhost:5432/simstudio'
@@ -1,7 +1,8 @@
import { spawnSync } from 'node:child_process'
import net from 'node:net'
import os from 'node:os'
import { ROOT, readEnvFile } from './env-files.ts'
import { ROOT, readEnvFile } from './env-files'
import { executableExists } from './executables'
export const MANAGED_LABEL = 'managed-by=sim-setup'
export const DB_CONTAINER = 'sim-postgres'
@@ -146,9 +147,9 @@ export async function runDetection(): Promise<Detection> {
shellLlmKeys: SHELL_LLM_KEYS.filter((key) => process.env[key]),
ollamaReachable: ollamaPortOpen ? await ollamaReachable() : false,
binaries: {
kubectl: Bun.which('kubectl') !== null,
helm: Bun.which('helm') !== null,
kind: Bun.which('kind') !== null,
kubectl: executableExists('kubectl'),
helm: executableExists('helm'),
kind: executableExists('kind'),
},
kubeContext: commandOutput('kubectl', ['config', 'current-context']),
specs: detectSpecs(dockerRunning),
@@ -2,10 +2,11 @@ import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { SetupError } from './errors.ts'
import { waitFor } from './probes.ts'
import * as p from './prompter.ts'
import { glyph, theme } from './theme.ts'
import { SetupError } from './errors'
import { executableExists } from './executables'
import { waitFor } from './probes'
import * as p from './prompter'
import { glyph, theme } from './theme'
const INSTALL_HINTS = [
'install Docker Desktop: https://docker.com/products/docker-desktop',
@@ -35,9 +36,8 @@ function daemonUp(): boolean {
return spawnSync('docker', ['info'], { stdio: 'ignore' }).status === 0
}
/** Uses `Bun.which` rather than `which`, which is not a standard Windows command. */
function installed(): boolean {
return Bun.which('docker') !== null
return executableExists('docker')
}
/**
@@ -1,5 +1,5 @@
import { type CheckGroup, type Finding, loadCheckContext, runChecks } from './checks.ts'
import { glyph, theme } from './theme.ts'
import { type CheckGroup, type Finding, loadCheckContext, runChecks } from './checks'
import { glyph, theme } from './theme'
const GROUP_TITLES: Record<CheckGroup, string> = {
files: 'Env files',
@@ -1,11 +1,21 @@
import { describe, expect, it } from 'bun:test'
import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
isPlaceholder,
isUsableSecret,
parseEnv,
reconcileEnvContent,
upsertEnv,
} from './env-files.ts'
writeEnvFile,
} from './env-files'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('placeholder detection', () => {
it('recognizes underscore and hyphen template prefixes', () => {
@@ -56,3 +66,18 @@ describe('reconcileEnvContent', () => {
expect(parseEnv(content).get('SMTP_HOST')).toBe('old-host')
})
})
describe('writeEnvFile', () => {
it('replaces existing contents with owner-only permissions', () => {
const root = mkdtempSync(path.join(tmpdir(), 'sim-setup-env-file-'))
roots.push(root)
const file = path.join(root, '.env')
writeFileSync(file, 'OLD=value\n')
chmodSync(file, 0o644)
writeEnvFile(file, 'SECRET=current\n')
expect(readFileSync(file, 'utf8')).toBe('SECRET=current\n')
expect(statSync(file).mode & 0o777).toBe(0o600)
})
})
@@ -1,9 +1,10 @@
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync, renameSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { generateRandomHex } from '@sim/utils/random'
import { writeTextFileAtomic } from './atomic-file'
import { SETUP_CONTEXT } from './context'
export const ROOT = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../..')
export const ROOT = SETUP_CONTEXT.root
export type EnvTarget = 'sim' | 'realtime' | 'db' | 'root'
@@ -147,7 +148,12 @@ export function reconcileEnvValues(
const example = EXAMPLE_PATHS[target]
content = example && existsSync(example) ? readFileSync(example, 'utf8') : ''
}
writeFileSync(filePath, reconcileEnvContent(content, remove, values))
writeEnvFile(filePath, reconcileEnvContent(content, remove, values))
}
/** Atomically replaces a secret-bearing env file with owner-only permissions. */
export function writeEnvFile(filePath: string, contents: string): void {
writeTextFileAtomic(filePath, contents, { mode: 0o600 })
}
/** Writes values into an env file, seeding a missing file from its .env.example. */
@@ -156,7 +162,11 @@ export function writeEnvValues(target: EnvTarget, values: Record<string, string>
}
export function archiveEnvFile(target: EnvTarget): string | null {
const filePath = ENV_PATHS[target]
return archiveFile(ENV_PATHS[target])
}
/** Archives an existing file next to itself and returns the backup path. */
export function archiveFile(filePath: string): string | null {
if (!existsSync(filePath)) return null
const backup = `${filePath}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`
renameSync(filePath, backup)
+8
View File
@@ -0,0 +1,8 @@
import { spawnSync } from 'node:child_process'
/** Checks executable resolution without depending on a shell or Bun runtime. */
export function executableExists(command: string): boolean {
const result = spawnSync(command, ['--version'], { stdio: 'ignore' })
if (!result.error) return true
return (result.error as NodeJS.ErrnoException).code !== 'ENOENT'
}
@@ -1,12 +1,12 @@
import { describe, expect, it } from 'bun:test'
import {
SANDBOX_CAPABILITY,
validateCapabilityFieldInput,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { SANDBOX_SETUP } from './capability-config.ts'
import { buildCapabilitySetupTransition } from './capability-setup.ts'
import type { ConfigurationSource } from './configuration-sources.ts'
import { reconcileLlmSetup, resolveFeatureSetupDestination } from './feature-setup.ts'
} from '@sim/deployment-config/env-capabilities'
import { describe, expect, it } from 'vitest'
import { SANDBOX_SETUP } from './capability-config'
import { buildCapabilitySetupTransition } from './capability-setup'
import type { ConfigurationSource } from './configuration-sources'
import { reconcileLlmSetup, resolveFeatureSetupDestination } from './feature-setup'
const E2B_FUNCTION_TEMPLATE_ID = 'sim-function:00000000-0000-4000-8000-000000000001'
const DAYTONA_FUNCTION_SNAPSHOT_ID = '00000000-0000-4000-8000-000000000002'
@@ -2,18 +2,18 @@ import {
LLM_KEY_POOLS,
OAUTH_CLIENT_CAPABILITIES,
resolveOAuthClientCapabilityId,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
} from '@sim/deployment-config/env-capabilities'
import {
getCapabilitySetup,
getOAuthClientSetupFields,
SETUP_FEATURES,
type SetupFeatureId,
} from './capability-config.ts'
import { promptCapabilitySetup } from './capability-setup.ts'
import { type ConfigurationSource, discoverConfigurationSources } from './configuration-sources.ts'
import { type EnvTarget, reconcileEnvValues } from './env-files.ts'
import * as p from './prompter.ts'
import { theme } from './theme.ts'
} from './capability-config'
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 { theme } from './theme'
function isSetupFeatureId(value: string): value is SetupFeatureId {
return SETUP_FEATURES.some((feature) => feature.id === value)
@@ -24,7 +24,7 @@ async function setupIntegration(
vars: Map<string, string>
): Promise<Record<string, string>> {
if (!requestedId) {
throw new Error('Missing integration id. Example: bun run setup integration slack')
throw new Error('Missing integration id. Example: npx @sim/setup add integration slack')
}
const providerId = resolveOAuthClientCapabilityId(requestedId)
if (!providerId) {
@@ -131,30 +131,30 @@ export function resolveFeatureSetupDestination(
sources: readonly ConfigurationSource[]
): FeatureSetupDestination {
if (sources.length === 0) {
throw new Error('No Sim configuration was detected. Run bun run setup first.')
throw new Error('No Sim configuration was detected. Run npx @sim/setup first.')
}
const managed = sources.filter((source) => source.managedByCurrentCheckout)
if (managed.length === 0) {
throw new Error(
'No effective configuration is safely writable by this checkout. Process overrides, higher-precedence development env files, external Compose projects, and Helm releases must be updated at their source. Run bun run setup status for the detected sources.'
'No effective configuration is safely writable by this checkout. Process overrides, higher-precedence development env files, external Compose projects, and Helm releases must be updated at their source. Run npx @sim/setup config for the detected sources.'
)
}
if (managed.length > 1) {
throw new Error(
`More than one effective configuration is writable by this checkout (${managed.map((source) => source.label).join(', ')}). Run bun run setup status and remove the ambiguity before configuring a feature.`
`More than one effective configuration is writable by this checkout (${managed.map((source) => source.label).join(', ')}). Run npx @sim/setup config and remove the ambiguity before configuring a feature.`
)
}
const source = managed[0]
if (!source.values) {
throw new Error(
`${source.label} is managed by this checkout, but its effective environment could not be resolved. Run bun run setup status and fix the reported source error first.`
`${source.label} is managed by this checkout, but its effective environment could not be resolved. Run npx @sim/setup config and fix the reported source error first.`
)
}
if (source.kind === 'helm') {
throw new Error(
'Helm configuration cannot be updated by bun run setup. Update the release Secret or values and upgrade the release.'
'Helm configuration cannot be updated by npx @sim/setup add. Update the release Secret or values and upgrade the release.'
)
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
import { getErrorMessage } from '@sim/utils/errors'
import { parseSetupArguments, SetupArgumentError } from './arguments'
import { SetupError } from './errors'
import { exitWith, restoreTerminal } from './terminal'
import { theme } from './theme'
import { SETUP_VERSION } from './version'
const SETUP_FEATURES =
'email | storage | sandbox | jobs | cache | knowledge | knowledge-embeddings | llm | integration <slug>'
const USAGE = `Usage:
sim-setup [--quick] [--dir <path>] [--mode compose|dev|k8s]
sim-setup config show configured capabilities and integrations
sim-setup add <feature> configure ${SETUP_FEATURES}
sim-setup doctor [--fix] [--json] check your setup
sim-setup start | stop | restart bring your install up / down / cycle
sim-setup update pull/rebuild and apply Compose images
sim-setup status show what's installed and healthy
sim-setup logs follow logs
sim-setup down remove containers (data kept)
sim-setup reset archive .env + wipe managed data
Note: dev and k8s modes require a Sim source checkout.`
async function main(): Promise<void> {
const invocation = parseSetupArguments(process.argv.slice(2))
if (invocation.kind === 'version') {
console.log(SETUP_VERSION)
return
}
if (invocation.kind === 'help') {
console.log(USAGE)
return
}
process.on('SIGINT', () => exitWith(130))
if (invocation.kind === 'config') {
const { runSetupStatus } = await import('./setup-status')
process.exitCode = await runSetupStatus()
return
}
if (invocation.kind === 'add') {
const { runFeatureSetup } = await import('./feature-setup')
await runFeatureSetup(invocation.feature, invocation.args)
return
}
if (invocation.kind === 'doctor') {
const { runDoctor } = await import('./doctor')
process.exitCode = await runDoctor({ fix: invocation.fix, json: invocation.json })
return
}
if (invocation.kind === 'lifecycle') {
const { runLifecycle } = await import('./lifecycle')
await runLifecycle(invocation.command)
return
}
if (invocation.kind === 'wizard') {
const { runWizard } = await import('./wizard')
await runWizard({ quick: invocation.quick, mode: invocation.mode })
return
}
}
function renderFailure(error: unknown): void {
const hints = error instanceof SetupError ? error.hints : []
console.error()
console.error(`${theme.error('✗ Setup failed')}\n`)
console.error(` ${getErrorMessage(error).split('\n').join('\n ')}`)
if (hints.length > 0) {
console.error(`\n ${theme.heading('Try:')}`)
for (const hint of hints) {
console.error(` ${theme.muted('•')} ${hint}`)
}
}
console.error(
`\n ${theme.muted('Your progress is saved — re-run')} ${theme.command('npx @sim/setup')} ${theme.muted('to pick up where you left off.')}`
)
}
main()
.catch((error) => {
if (error instanceof SetupArgumentError) {
console.error(`Error: ${error.message}\n`)
console.error(USAGE)
} else {
renderFailure(error)
}
process.exitCode = 1
})
.finally(restoreTerminal)
+75
View File
@@ -0,0 +1,75 @@
import { createHash } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { ensureProductionComposeFile } from './compose-asset'
import {
composeInstallFromDirectory,
getComposeUpdateMode,
isLifecycleCommand,
refreshComposeFileForUpdate,
} from './lifecycle'
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/)
})
it('refreshes a discovered standalone install outside the current setup context', () => {
const root = mkdtempSync(path.join(tmpdir(), 'sim-setup-lifecycle-'))
try {
const composeFile = ensureProductionComposeFile({ kind: 'standalone', root, existing: false })
const packaged = readFileSync(composeFile, 'utf8')
const previous = `${packaged}\nservices: {}\n`
writeFileSync(composeFile, previous)
writeFileSync(
path.join(root, '.sim-setup.json'),
JSON.stringify({
schemaVersion: 1,
composeSha256: createHash('sha256').update(previous).digest('hex'),
})
)
expect(refreshComposeFileForUpdate(composeFile, root)).toBe(composeFile)
expect(readFileSync(composeFile, 'utf8')).toBe(packaged)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('restores a downed standalone install from its persisted project name', () => {
const root = mkdtempSync(path.join(tmpdir(), 'sim-setup-lifecycle-'))
try {
ensureProductionComposeFile({ kind: 'standalone', root, existing: false })
writeFileSync(path.join(root, '.env'), 'COMPOSE_PROJECT_NAME=sim-a1b2c3d4e5f6\n')
expect(composeInstallFromDirectory(root, [])).toEqual({
kind: 'compose',
file: path.join(root, 'docker-compose.prod.yml'),
dir: root,
project: 'sim-a1b2c3d4e5f6',
})
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('does not duplicate a running install restored from its directory', () => {
const root = mkdtempSync(path.join(tmpdir(), 'sim-setup-lifecycle-'))
try {
const file = ensureProductionComposeFile({ kind: 'standalone', root, existing: false })
const active = [{ kind: 'compose', file, dir: root, project: 'sim-live' }] as const
expect(composeInstallFromDirectory(root, active)).toBeNull()
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
@@ -1,14 +1,17 @@
import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { DB_CONTAINER, type Detection, REDIS_CONTAINER, runDetection } from './detect.ts'
import { archiveEnvFile, ROOT } from './env-files.ts'
import { SetupError } from './errors.ts'
import { forwardCommands, isLocalKubeContext } from './modes/k8s.ts'
import { httpHealth } from './probes.ts'
import * as p from './prompter.ts'
import { glyph, theme } from './theme.ts'
import { APP_SIGNUP_URL, APP_URL } from './urls.ts'
import { ensureProductionComposeFile } from './compose-asset'
import { legacyComposeProjectName } from './compose-project'
import { directoryOverride, resolveSetupContextAtRoot, SETUP_CONTEXT } from './context'
import { DB_CONTAINER, type Detection, REDIS_CONTAINER, runDetection } from './detect'
import { archiveEnvFile, archiveFile, parseEnv, ROOT } from './env-files'
import { SetupError } from './errors'
import { forwardCommands, isLocalKubeContext } from './modes/k8s'
import { httpHealth } from './probes'
import * as p from './prompter'
import { glyph, theme } from './theme'
import { APP_SIGNUP_URL, APP_URL } from './urls'
const REALTIME_HEALTH = 'http://localhost:3002/health'
const POSTGRES_VOLUME = 'sim-postgres-data'
@@ -65,7 +68,7 @@ function dockerRun(args: string[], failMessage: string, cwd: string = ROOT): voi
}
}
interface ComposeInstall {
export interface ComposeInstall {
kind: 'compose'
/** Absolute path to the compose file Docker recorded for the project. */
file: string
@@ -101,7 +104,7 @@ const SIM_COMPOSE_MARKERS = ['ghcr.io/simstudioai/simstudio', 'docker/app.Docker
/**
* `docker-compose.prod.yml` is a common filename, so the name alone cannot say a
* project is ours — and `sim reset` runs `compose down -v`, which would destroy
* project is ours — and `reset` runs `compose down -v`, which would destroy
* an unrelated stack's volumes. Read the file Docker recorded for the project and
* require a Sim marker inside it. The old ROOT-scoped `-f` probe was implicitly
* safe because it could only ever see the local project; discovering projects
@@ -156,6 +159,28 @@ function composeInstalls(): ComposeInstall[] {
return installs
}
/** Restores a setup-managed Compose target from disk even when `down` removed every container. */
export function composeInstallFromDirectory(
root: string,
activeInstalls: readonly ComposeInstall[]
): ComposeInstall | null {
const file = path.join(root, 'docker-compose.prod.yml')
if (!isSimComposeFile(file)) return null
if (activeInstalls.some((install) => path.resolve(install.file) === path.resolve(file)))
return null
const envFile = path.join(root, '.env')
const configuredProject = existsSync(envFile)
? parseEnv(readFileSync(envFile, 'utf8')).get('COMPOSE_PROJECT_NAME')
: undefined
return {
kind: 'compose',
file,
dir: root,
project: configuredProject || legacyComposeProjectName(root),
}
}
/**
* Compose args for an op on a detected install. `-p` is not optional: without it
* Compose re-derives the project from the working directory, and that name is
@@ -201,7 +226,12 @@ function k8sInstall(detection: Detection): K8sInstall | null {
}
function detectInstalls(detection: Detection): Install[] {
const installs: Install[] = [...composeInstalls()]
const compose = composeInstalls()
if (SETUP_CONTEXT.kind === 'standalone' && SETUP_CONTEXT.existing) {
const fromDirectory = composeInstallFromDirectory(SETUP_CONTEXT.root, compose)
if (fromDirectory) compose.push(fromDirectory)
}
const installs: Install[] = [...compose]
const dev = devInstall(detection)
if (dev) installs.push(dev)
const k8s = k8sInstall(detection)
@@ -209,6 +239,16 @@ function detectInstalls(detection: Detection): Install[] {
return installs
}
/** Limits an explicit standalone `--dir` command to that installation. */
function scopeInstallsToInvocation(installs: Install[]): Install[] {
if (SETUP_CONTEXT.kind !== 'standalone' || directoryOverride(process.argv.slice(2)) === null) {
return installs
}
return installs.filter(
(install) => install.kind === 'compose' && path.resolve(install.dir) === SETUP_CONTEXT.root
)
}
function describeInstall(install: Install): string {
if (install.kind === 'compose')
return `Docker Compose (project ${install.project} in ${install.dir})`
@@ -259,7 +299,11 @@ function start(install: Install): void {
dockerRun(composeArgs(install, 'up', '-d'), 'docker compose up failed', install.dir)
spin.stop('Containers up')
p.note(
[`open ${APP_SIGNUP_URL}`, 'follow logs: sim logs', 'stop: sim stop'].join('\n'),
[
`open ${APP_SIGNUP_URL}`,
'follow logs: npx @sim/setup logs',
'stop: npx @sim/setup stop',
].join('\n'),
'Running'
)
return
@@ -269,7 +313,10 @@ function start(install: Install): void {
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'),
[
'start the dev server: bun run dev:full',
'stop DB/Redis: npx @sim/setup stop',
].join('\n'),
'Ready'
)
return
@@ -283,7 +330,10 @@ function stop(install: Install): void {
spin.start('Stopping containers…')
dockerRun(composeArgs(install, 'stop'), 'docker compose stop failed', install.dir)
spin.stop('Containers stopped (data kept)')
p.note(['start again: sim start', 'remove: sim down'].join('\n'), 'Stopped')
p.note(
['start again: npx @sim/setup start', 'remove: npx @sim/setup down'].join('\n'),
'Stopped'
)
return
}
if (install.kind === 'dev') {
@@ -301,7 +351,7 @@ function stop(install: Install): void {
[
`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',
'tear down: npx @sim/setup down',
].join('\n'),
'Kubernetes'
)
@@ -336,14 +386,20 @@ export function getComposeUpdateMode(file: string): ComposeUpdateMode {
throw new Error(`Unsupported Sim Compose file: ${file}`)
}
/** Refreshes a published install's managed Compose file before applying an update. */
export function refreshComposeFileForUpdate(file: string, dir: string): string {
if (getComposeUpdateMode(file) === 'build') return file
return ensureProductionComposeFile(resolveSetupContextAtRoot(dir))
}
function update(install: Install): void {
if (install.kind === 'dev') {
throw new SetupError('sim update is only available for Docker Compose installs.', [
throw new SetupError('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.', [
throw new SetupError('update does not upgrade Kubernetes releases.', [
'upgrade the release with helm after reviewing the chart and release notes',
])
}
@@ -351,6 +407,7 @@ function update(install: Install): void {
const mode = getComposeUpdateMode(install.file)
const spin = p.spinner()
if (mode === 'pull') {
install.file = refreshComposeFileForUpdate(install.file, install.dir)
spin.start('Pulling configured Sim images…')
dockerRun(composeArgs(install, 'pull'), 'docker compose pull failed', install.dir)
} else {
@@ -363,8 +420,8 @@ function update(install: Install): void {
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')}`,
`check: ${theme.command('npx @sim/setup status')}`,
`logs: ${theme.command('npx @sim/setup logs')}`,
].join('\n'),
'Update complete'
)
@@ -441,9 +498,14 @@ async function reset(install: Install | null): Promise<void> {
p.log.info('Reset cancelled.')
return
}
for (const target of ['sim', 'realtime', 'db', 'root'] as const) {
const backup = archiveEnvFile(target)
if (install?.kind === 'compose') {
const backup = archiveFile(path.join(install.dir, '.env'))
if (backup) p.log.step(`Archived ${backup}`)
} else {
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(composeArgs(install, 'down', '-v'), 'docker compose down -v failed', install.dir)
@@ -472,12 +534,12 @@ async function reset(install: Install | null): Promise<void> {
}
p.log.step(`Uninstalled ${K8S_RELEASE}`)
}
p.note(`start fresh with ${theme.command('sim setup')}`, 'Reset complete')
p.note(`start fresh with ${theme.command('npx @sim/setup')}`, 'Reset complete')
}
async function status(): Promise<void> {
const detection = await runDetection()
const installs = detectInstalls(detection)
const installs = scopeInstallsToInvocation(detectInstalls(detection))
const docker = dockerReachable()
console.log(`\n${theme.heading('◆ Sim status')}\n`)
// Every container probe goes through Docker, so when the daemon is down the
@@ -493,7 +555,7 @@ async function status(): Promise<void> {
if (installs.length === 0) {
console.log(
docker
? ` ${glyph.warn} No Sim install detected — run ${theme.command('sim setup')}.`
? ` ${glyph.warn} No Sim install detected — run ${theme.command('npx @sim/setup')}.`
: ` ${glyph.warn} No install detected, but that may just be Docker being down.`
)
return
@@ -516,14 +578,14 @@ async function status(): Promise<void> {
export async function runLifecycle(command: LifecycleCommand): Promise<void> {
if (command === 'status') return status()
const installs = detectInstalls(await runDetection())
const installs = scopeInstallsToInvocation(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.`)
p.log.warn(`No Sim install detected. Run ${theme.command('npx @sim/setup')} first.`)
return
}
switch (command) {
@@ -1,13 +1,17 @@
import { spawnSync } from 'node:child_process'
import { EMAIL_SETUP, STORAGE_SETUP } from '../capability-config.ts'
import { promptCapabilitySetup, stageCapabilitySetupTransition } from '../capability-setup.ts'
import type { Detection } from '../detect.ts'
import { ensureDocker } from '../docker.ts'
import { ROOT, readEnvFile, reconcileEnvValues } from '../env-files.ts'
import { SetupError } from '../errors.ts'
import { ensurePortsFree } from '../ports.ts'
import { httpHealth, waitFor } from '../probes.ts'
import * as p from '../prompter.ts'
import path from 'node:path'
import { EMAIL_SETUP, STORAGE_SETUP } from '../capability-config'
import { promptCapabilitySetup, stageCapabilitySetupTransition } from '../capability-setup'
import { ensureProductionComposeFile } from '../compose-asset'
import { legacyComposeProjectName, standaloneComposeProjectName } from '../compose-project'
import { SETUP_CONTEXT } from '../context'
import type { Detection } from '../detect'
import { ensureDocker } from '../docker'
import { ROOT, readEnvFile, reconcileEnvValues } from '../env-files'
import { SetupError } from '../errors'
import { ensurePortsFree } from '../ports'
import { httpHealth, waitFor } from '../probes'
import * as p from '../prompter'
import {
chatFlagValues,
collectSecrets,
@@ -18,20 +22,60 @@ import {
promptSecurity,
promptSignInProviders,
promptUnlocks,
} from '../steps.ts'
import { glyph, theme } from '../theme.ts'
import { APP_SIGNUP_URL, APP_URL } from '../urls.ts'
} from '../steps'
import { glyph, theme } from '../theme'
import { APP_SIGNUP_URL, APP_URL } from '../urls'
const REQUIRED_PORTS = [3000, 3002] as const
interface ComposeProject {
Name: string
ConfigFiles: string
}
/** Returns the active Compose project already associated with this exact configuration file. */
function activeComposeProject(composeFile: string): string | null {
const result = spawnSync('docker', ['compose', 'ls', '-a', '--format', 'json'], {
encoding: 'utf8',
})
if (result.status !== 0) return null
let projects: ComposeProject[]
try {
projects = JSON.parse(result.stdout)
} catch {
return null
}
const target = path.resolve(composeFile)
return (
projects.find((project) =>
project.ConfigFiles.split(',').some((file) => path.resolve(file.trim()) === target)
)?.Name ?? null
)
}
/** Builds a Compose command pinned to the selected standalone project when one is present. */
function composeArgs(
composeFile: string,
project: string | undefined,
...args: string[]
): string[] {
return ['compose', ...(project ? ['-p', project] : []), '-f', composeFile, ...args]
}
/** Formats the pinned Compose prefix used in copyable diagnostics. */
function composeCommand(composeFile: string, project: string | undefined): string {
return `docker compose${project ? ` -p ${project}` : ''} -f ${composeFile}`
}
/**
* Host ports this compose project currently publishes. Read from the containers
* rather than assumed from the file, because what matters is what is bound right
* now — a project with only db/redis up publishes neither app port, so those
* still need the conflict check.
*/
function composePublishedPorts(composeFile: string): Set<number> {
const ids = spawnSync('docker', ['compose', '-f', composeFile, 'ps', '-q'], {
function composePublishedPorts(composeFile: string, project: string | undefined): Set<number> {
const ids = spawnSync('docker', composeArgs(composeFile, project, 'ps', '-q'), {
cwd: ROOT,
encoding: 'utf8',
})
@@ -62,14 +106,17 @@ function composePublishedPorts(composeFile: string): Set<number> {
* instead of letting `docker compose up` die halfway through startup. Aborting
* is fatal here: compose can't come up while the ports are held.
*/
async function ensureComposePortsFree(composeFile: string): Promise<void> {
async function ensureComposePortsFree(
composeFile: string,
project: string | undefined
): Promise<void> {
// A port this stack already publishes is not a conflict — `docker compose up
// -d` reconciles its own containers, and reporting the install's own realtime
// container as a blocker (offering to kill Docker's listener) is never right.
// Skip only the ports this project actually publishes: leftover db/redis
// containers must not wave through a foreign process sitting on 3000, which
// would otherwise surface as a raw compose bind error instead of the prompt.
const ours = composePublishedPorts(composeFile)
const ours = composePublishedPorts(composeFile, project)
const toCheck = REQUIRED_PORTS.filter((port) => !ours.has(port))
if (toCheck.length < REQUIRED_PORTS.length) {
const skipped = REQUIRED_PORTS.filter((port) => ours.has(port))
@@ -78,7 +125,7 @@ async function ensureComposePortsFree(composeFile: string): Promise<void> {
if (toCheck.length === 0) return
if (await ensurePortsFree(toCheck)) return
throw new SetupError(`ports ${toCheck.map((port) => `:${port}`).join('/')} are in use`, [
`free the ports, then re-run: ${theme.command('bun run setup')}`,
`free the ports, then re-run: ${theme.command('npx @sim/setup')}`,
`see what holds them: ${theme.command('lsof -nP -iTCP:3000 -sTCP:LISTEN')}`,
`stop a container publishing them: ${theme.command('docker ps')}`,
`compose file in play: ${composeFile}`,
@@ -87,29 +134,51 @@ async function ensureComposePortsFree(composeFile: string): Promise<void> {
export async function runComposeMode(detection: Detection, quick: boolean): Promise<void> {
await ensureDocker(true)
const variant = quick
? 'prod'
: await p.select({
message: 'Which images?',
options: [
{
value: 'prod',
label: 'Published images',
hint: 'pulls ghcr.io/simstudioai/* — fastest',
},
{
value: 'local',
label: 'Build from source',
hint: 'builds docker/*.Dockerfile — for testing local changes',
},
],
initialValue: 'prod',
})
const composeFile = variant === 'prod' ? 'docker-compose.prod.yml' : 'docker-compose.local.yml'
const variant =
SETUP_CONTEXT.kind === 'standalone' || quick
? 'prod'
: await p.select({
message: 'Which images?',
options: [
{
value: 'prod',
label: 'Published images',
hint: 'pulls ghcr.io/simstudioai/* — fastest',
},
{
value: 'local',
label: 'Build from source',
hint: 'builds docker/*.Dockerfile — for testing local changes',
},
],
initialValue: 'prod',
})
const composeFile =
variant === 'prod' ? ensureProductionComposeFile() : path.join(ROOT, 'docker-compose.local.yml')
const root = readEnvFile('root')
const values = collectSecrets(root)
const remove = new Set<string>()
const configuredComposeProject = root.vars.get('COMPOSE_PROJECT_NAME')
const activeProject =
SETUP_CONTEXT.kind === 'standalone' ? activeComposeProject(composeFile) : null
if (configuredComposeProject && activeProject && configuredComposeProject !== activeProject) {
throw new SetupError(
`COMPOSE_PROJECT_NAME is ${configuredComposeProject}, but ${composeFile} is running as project ${activeProject}.`,
['stop the active project or restore its name in .env before running setup again']
)
}
const composeProject =
SETUP_CONTEXT.kind === 'standalone'
? (configuredComposeProject ??
activeProject ??
(SETUP_CONTEXT.existing
? legacyComposeProjectName(ROOT)
: standaloneComposeProjectName(ROOT)))
: undefined
if (composeProject && !configuredComposeProject) {
values.COMPOSE_PROJECT_NAME = composeProject
}
// Before the key is minted: a half-set override mints against one environment
// and validates against the other, and warning afterwards is too late — the
// bad key is already stored, and the next run offers to keep it.
@@ -150,18 +219,29 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom
reconcileEnvValues('root', [...remove], values)
p.log.step('Wrote .env (compose reads it for variable substitution)')
await ensureComposePortsFree(composeFile)
const validation = spawnSync('docker', composeArgs(composeFile, composeProject, 'config'), {
cwd: ROOT,
encoding: 'utf8',
})
if (validation.status !== 0) {
throw new SetupError(
`docker compose config failed: ${validation.stderr.trim() || validation.stdout.trim()}`
)
}
p.log.step(`Running docker compose -f ${composeFile} up -d`)
const result = spawnSync('docker', ['compose', '-f', composeFile, 'up', '-d'], {
await ensureComposePortsFree(composeFile, composeProject)
const command = composeCommand(composeFile, composeProject)
p.log.step(`Running ${command} up -d`)
const result = spawnSync('docker', composeArgs(composeFile, composeProject, 'up', '-d'), {
cwd: ROOT,
stdio: 'inherit',
})
if (result.status !== 0) {
throw new SetupError(`docker compose exited with ${result.status}.`, [
`inspect what failed: ${theme.command(`docker compose -f ${composeFile} logs --tail 50`)}`,
`container status: ${theme.command(`docker compose -f ${composeFile} ps`)}`,
`clean slate: ${theme.command(`docker compose -f ${composeFile} down`)} then re-run the wizard`,
`inspect what failed: ${theme.command(`${command} logs --tail 50`)}`,
`container status: ${theme.command(`${command} ps`)}`,
`clean slate: ${theme.command(`${command} down`)} then re-run the wizard`,
])
}
@@ -175,7 +255,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom
throw new SetupError(
`${!appHealthy ? 'the app (:3000)' : 'realtime (:3002)'} never answered its health check.`,
[
`follow the logs: ${theme.command(`docker compose -f ${composeFile} logs -f`)}`,
`follow the logs: ${theme.command(`${command} logs -f`)}`,
`first boots on slow disks can exceed the wait — if containers are still starting, just wait and open ${APP_SIGNUP_URL}`,
]
)
@@ -1,15 +1,15 @@
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import { truncate } from '@sim/utils/string'
import { EMAIL_SETUP, JOBS_SETUP, STORAGE_SETUP } from '../capability-config.ts'
import { promptCapabilitySetup, stageCapabilitySetupTransition } from '../capability-setup.ts'
import { resolveDatabase } from '../db.ts'
import type { Detection } from '../detect.ts'
import { ROOT, readEnvFile, reconcileEnvValues, writeEnvValues } from '../env-files.ts'
import { SetupError } from '../errors.ts'
import { pgProbe } from '../probes.ts'
import * as p from '../prompter.ts'
import { ensureRedis, resolveRedis } from '../redis.ts'
import { EMAIL_SETUP, JOBS_SETUP, STORAGE_SETUP } from '../capability-config'
import { promptCapabilitySetup, stageCapabilitySetupTransition } from '../capability-setup'
import { resolveDatabase } from '../db'
import type { Detection } from '../detect'
import { ROOT, readEnvFile, reconcileEnvValues, writeEnvValues } from '../env-files'
import { SetupError } from '../errors'
import { pgProbe } from '../probes'
import * as p from '../prompter'
import { ensureRedis, resolveRedis } from '../redis'
import {
chatFlagValues,
collectSecrets,
@@ -20,9 +20,9 @@ import {
promptSecurity,
promptSignInProviders,
promptUnlocks,
} from '../steps.ts'
import { glyph, theme } from '../theme.ts'
import { APP_URL } from '../urls.ts'
} from '../steps'
import { glyph, theme } from '../theme'
import { APP_URL } from '../urls'
/**
* A migrate failure on a never-migrated database means setup failed — abort.
@@ -1,21 +1,21 @@
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 { KNOWLEDGE_EMBEDDINGS_SETUP } from '../capability-config'
import { getCapabilitySetupFields, stageCapabilitySetupTransition } from '../capability-setup'
import type { Detection } from '../detect'
import { ensureDocker } from '../docker'
import { generateSecret, ROOT } from '../env-files'
import { SetupError } from '../errors'
import { waitFor } from '../probes'
import * as p from '../prompter'
import {
chatFlagValues,
mothershipOverride,
promptCopilotKey,
promptKnowledgeEmbeddings,
} from '../steps.ts'
import { glyph, theme } from '../theme.ts'
import { APP_SIGNUP_URL, APP_URL } from '../urls.ts'
} from '../steps'
import { glyph, theme } from '../theme'
import { APP_SIGNUP_URL, APP_URL } from '../urls'
const RELEASE = 'sim-dev'
const NAMESPACE = 'sim-dev'
@@ -174,7 +174,7 @@ async function ensureLocalContext(detection: Detection): Promise<string> {
spin.stop(`${glyph.fail} kind cluster "sim" would not start`)
throw new SetupError('the kind cluster "sim" exists but will not come up.', [
`inspect it: ${theme.command('docker ps -a --filter name=sim-control-plane')}`,
`recreate it: ${theme.command('kind delete cluster --name sim')}, then re-run ${theme.command('bun run setup')}`,
`recreate it: ${theme.command('kind delete cluster --name sim')}, then re-run ${theme.command('npx @sim/setup')}`,
])
}
spin.stop('kind cluster "sim" started')
@@ -1,8 +1,8 @@
import { getErrorMessage } from '@sim/utils/errors'
import { type PortOwnerInfo, portOpen, portOwner } from './detect.ts'
import { waitFor } from './probes.ts'
import * as p from './prompter.ts'
import { theme } from './theme.ts'
import { type PortOwnerInfo, portOpen, portOwner } from './detect'
import { waitFor } from './probes'
import * as p from './prompter'
import { theme } from './theme'
interface BusyPort {
port: number
@@ -1,21 +1,22 @@
import { readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import tls from 'node:tls'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import postgres from 'postgres'
import { ROOT } from './env-files.ts'
import { ROOT } from './env-files'
export interface PgProbeResult {
ok: boolean
error?: string
pgvectorAvailable?: boolean
migrations?: { applied: number | null; journal: number }
migrations?: { applied: number | null; journal: number | null }
}
function journalMigrationCount(): number {
function journalMigrationCount(): number | null {
const journalPath = path.join(ROOT, 'packages/db/migrations/meta/_journal.json')
if (!existsSync(journalPath)) return null
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries: unknown[] }
return journal.entries.length
}
@@ -1,6 +1,6 @@
import * as clack from '@clack/prompts'
import { exitWith } from './terminal.ts'
import { isRich, theme } from './theme.ts'
import { exitWith } from './terminal'
import { isRich, theme } from './theme'
const SPINNER_FRAMES = ['◐', '◓', '◑', '◒']
@@ -18,6 +18,17 @@ export interface SelectOption<T extends string> {
hint?: string
}
function formatOptions<T extends string>(options: SelectOption<T>[]) {
return options.map((option) => {
if (option.hint === undefined) return { value: option.value, label: option.label }
return {
value: option.value,
label: option.label,
hint: isRich() ? theme.muted(option.hint) : option.hint,
}
})
}
export async function select<T extends string>(params: {
message: string
options: SelectOption<T>[]
@@ -26,11 +37,8 @@ export async function select<T extends string>(params: {
return guardCancel(
await clack.select({
message: isRich() ? theme.accent(params.message) : params.message,
options: params.options.map((o) => ({
...o,
hint: o.hint && isRich() ? theme.muted(o.hint) : o.hint,
})),
initialValue: params.initialValue,
options: formatOptions(params.options) as clack.Option<T>[],
...(params.initialValue === undefined ? {} : { initialValue: params.initialValue }),
})
)
}
@@ -43,8 +51,8 @@ export async function multiselect<T extends string>(params: {
return guardCancel(
await clack.multiselect({
message: isRich() ? theme.accent(params.message) : params.message,
options: params.options,
initialValues: params.initialValues,
options: formatOptions(params.options) as clack.Option<T>[],
...(params.initialValues === undefined ? {} : { initialValues: params.initialValues }),
required: false,
})
)
@@ -60,10 +68,12 @@ export async function text(params: {
return guardCancel(
await clack.text({
message: isRich() ? theme.accent(params.message) : params.message,
placeholder: params.placeholder,
initialValue: params.initialValue,
defaultValue: params.defaultValue,
validate: params.validate,
...(params.placeholder === undefined ? {} : { placeholder: params.placeholder }),
...(params.initialValue === undefined ? {} : { initialValue: params.initialValue }),
...(params.defaultValue === undefined ? {} : { defaultValue: params.defaultValue }),
...(params.validate === undefined
? {}
: { validate: (value: string | undefined) => params.validate?.(value ?? '') }),
})
)
}
@@ -75,7 +85,9 @@ export async function password(params: {
return guardCancel(
await clack.password({
message: isRich() ? theme.accent(params.message) : params.message,
validate: params.validate,
...(params.validate === undefined
? {}
: { validate: (value: string | undefined) => params.validate?.(value ?? '') }),
})
)
}
@@ -87,7 +99,7 @@ export async function confirm(params: {
return guardCancel(
await clack.confirm({
message: isRich() ? theme.accent(params.message) : params.message,
initialValue: params.initialValue,
...(params.initialValue === undefined ? {} : { initialValue: params.initialValue }),
})
)
}
@@ -1,11 +1,11 @@
import { spawnSync } from 'node:child_process'
import { docker } from './db.ts'
import { type Detection, REDIS_CONTAINER } from './detect.ts'
import { ensureDocker } from './docker.ts'
import { SetupError } from './errors.ts'
import { redisPing, waitFor } from './probes.ts'
import * as p from './prompter.ts'
import { glyph, theme } from './theme.ts'
import { docker } from './db'
import { type Detection, REDIS_CONTAINER } from './detect'
import { ensureDocker } from './docker'
import { SetupError } from './errors'
import { redisPing, waitFor } from './probes'
import * as p from './prompter'
import { glyph, theme } from './theme'
const LOCAL_URL = 'redis://localhost:6379'
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'bun:test'
import { describe, expect, it } from 'vitest'
import type { ConfigurationSource } from './configuration-sources'
import { buildSetupStatusReport, renderSetupStatusReport } from './setup-status'
@@ -76,7 +76,7 @@ describe('setup status', () => {
expect(report.capabilityStatus?.features.email.state).toBe('configured')
expect(output).toContain('Email delivery: Resend')
expect(output).toContain('SMTP_PORT')
expect(output).toContain('configure: bun run setup email')
expect(output).toContain('configure: npx @sim/setup add email')
expect(output).not.toContain('resend-super-secret')
})
@@ -1,21 +1,21 @@
import {
type EnvCapabilityValues,
hasEnvCapabilityValue,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
} from '@sim/deployment-config/env-capabilities'
import {
type IntegrationAvailability,
resolveIntegrationAvailability,
} from '../../apps/sim/lib/integrations/availability.ts'
import { SETUP_FEATURES } from './capability-config.ts'
} from '@sim/deployment-config/integration-availability'
import { SETUP_FEATURES } from './capability-config'
import {
buildEnvCapabilityStatus,
type EnvCapabilityFeatureStatuses,
type SetupStatusFeatureId,
} from './capability-status.ts'
import { REQUIRED_APP_KEYS } from './checks.ts'
import { type ConfigurationSource, discoverConfigurationSources } from './configuration-sources.ts'
import { isPlaceholder, isUsableSecret } from './env-files.ts'
import { glyph, theme } from './theme.ts'
} from './capability-status'
import { REQUIRED_APP_KEYS } from './checks'
import { type ConfigurationSource, discoverConfigurationSources } from './configuration-sources'
import { isPlaceholder, isUsableSecret } from './env-files'
import { glyph, theme } from './theme'
type FeatureStatus = EnvCapabilityFeatureStatuses[keyof EnvCapabilityFeatureStatuses]
@@ -132,7 +132,7 @@ function featureGlyph(feature: FeatureStatus): string {
}
function withoutSetupCommand(message: string): string {
return message.replace(/\s+Run bun run setup[^.]*\.$/, '')
return message.replace(/\s+Run npx @sim\/setup setup[^.]*\.$/, '')
}
function setupHint(source: SetupStatusSource, command: string): string | null {
@@ -330,7 +330,7 @@ export function renderSetupStatusReport(report: SetupStatusReport): string {
const representedOAuthClients = new Set(
deploymentIntegrations.flatMap((integration) => {
if (!integration.setupCommand) return []
return [integration.setupCommand.replace('bun run setup integration ', '')]
return [integration.setupCommand.replace('npx @sim/setup add integration ', '')]
})
)
const additionalOAuthClients = Object.values(report.capabilityStatus.oauthClients.clients).filter(
@@ -357,7 +357,7 @@ export async function runSetupStatus(): Promise<number> {
console.log(`\n${theme.heading('◆ Sim setup status')}\n`)
if (sources.length === 0) {
console.log(` ${glyph.fail} No local-dev, Docker Compose, or Helm configuration detected.`)
console.log(` ${theme.muted('run: bun run setup')}`)
console.log(` ${theme.muted('run: npx @sim/setup')}`)
return 1
}
@@ -1,4 +1,3 @@
import { describe, expect, it } from 'bun:test'
import {
EMAIL_CAPABILITY,
inspectCapability,
@@ -6,12 +5,13 @@ import {
requireCapability,
STORAGE_CAPABILITY,
validateCapabilityFieldInput,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { EMAIL_SETUP, KNOWLEDGE_EMBEDDINGS_SETUP, STORAGE_SETUP } from './capability-config.ts'
} from '@sim/deployment-config/env-capabilities'
import { describe, expect, it } from 'vitest'
import { EMAIL_SETUP, KNOWLEDGE_EMBEDDINGS_SETUP, STORAGE_SETUP } from './capability-config'
import {
buildCapabilitySetupTransition,
resolveCurrentCapabilitySetupOptionId,
} from './capability-setup.ts'
} from './capability-setup'
function applyResult(
initial: Record<string, string>,
@@ -1,11 +1,12 @@
import { KNOWLEDGE_EMBEDDINGS_SETUP } from './capability-config.ts'
import { KNOWLEDGE_EMBEDDINGS_SETUP } from './capability-config'
import {
type CapabilitySetupContext,
type EnvCapabilitySetupTransition,
promptOptionalCapabilitySetup,
} from './capability-setup.ts'
import { browserKeyFlow } from './cli-auth.ts'
import type { Detection } from './detect.ts'
} from './capability-setup'
import { browserKeyFlow } from './cli-auth'
import { SETUP_CONTEXT } from './context'
import type { Detection } from './detect'
import {
type EnvFile,
generateSecret,
@@ -14,10 +15,10 @@ import {
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'
} from './env-files'
import * as p from './prompter'
import { link, theme } from './theme'
import { FLAG_TWINS, LOGIN_PROVIDERS, SELF_HOST_UNLOCKS } from './twins'
/** Where the Chat key is minted when SIM_CLI_AUTH_ORIGIN is unset. */
const DEFAULT_CLI_AUTH_ORIGIN = 'https://www.sim.ai'
@@ -71,7 +72,7 @@ export async function promptCopilotKey(existing?: string): Promise<string | null
// 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.'
'No key received — re-run npx @sim/setup to retry, or set COPILOT_API_KEY and NEXT_PUBLIC_CHAT_DISABLED=false yourself.'
)
return null
}
@@ -95,7 +96,7 @@ export function chatFlagValues(copilotKey: string | null): Record<string, string
*
* SIM_CLI_AUTH_ORIGIN=https://www.staging.sim.ai \
* SIM_AGENT_API_URL=https://www.staging.copilot.sim.ai \
* bun run setup
* npx @sim/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
@@ -130,7 +131,7 @@ export async function promptLlmKeys(
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?',
message: `Found LLM API keys in your shell — copy into ${SETUP_CONTEXT.kind === 'source' ? 'apps/sim/.env' : '.env'}?`,
options: detection.shellLlmKeys.map((key) => ({ value: key, label: key })),
initialValues: detection.shellLlmKeys,
})
@@ -1,7 +1,4 @@
import {
EMAIL_CAPABILITY,
inspectCapability,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { EMAIL_CAPABILITY, inspectCapability } from '@sim/deployment-config/env-capabilities'
/**
* Server/client feature-flag pairs that must be set together — server code
+22
View File
@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs'
function readPackageVersion(): string {
const metadata: unknown = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf8')
)
if (
typeof metadata !== 'object' ||
metadata === null ||
!('version' in metadata) ||
typeof metadata.version !== 'string'
) {
throw new Error('@sim/setup package metadata is missing a valid version')
}
return metadata.version
}
/** The version in the manifest npm installs alongside the bundled executable. */
export const SETUP_VERSION = readPackageVersion()
/** Identifies setup traffic by package, runtime, platform, and architecture. */
export const SETUP_USER_AGENT = `sim-setup/${SETUP_VERSION} node/${process.versions.node} (${process.platform}; ${process.arch})`
@@ -1,15 +1,16 @@
import { spawnSync } from 'node:child_process'
import { showBanner } from './banner.ts'
import { loadCheckContext, runChecks } from './checks.ts'
import { type Detection, runDetection } from './detect.ts'
import { archiveEnvFile, ROOT } from './env-files.ts'
import { runComposeMode } from './modes/compose.ts'
import { runDevMode } from './modes/dev.ts'
import { runK8sMode } from './modes/k8s.ts'
import { ensurePortsFree } from './ports.ts'
import * as p from './prompter.ts'
import { glyph, theme } from './theme.ts'
import { APP_SIGNUP_URL } from './urls.ts'
import { showBanner } from './banner'
import { loadCheckContext, runChecks } from './checks'
import { SETUP_CONTEXT } from './context'
import { type Detection, runDetection } from './detect'
import { archiveEnvFile, ROOT } from './env-files'
import { runComposeMode } from './modes/compose'
import { runDevMode } from './modes/dev'
import { runK8sMode } from './modes/k8s'
import { ensurePortsFree } from './ports'
import * as p from './prompter'
import { glyph, theme } from './theme'
import { APP_SIGNUP_URL } from './urls'
export type WizardMode = 'compose' | 'dev' | 'k8s'
@@ -52,6 +53,14 @@ const LOW_DOCKER_MEM_GB = 6
const LOW_DISK_GB = 15
async function selectMode(detection: Detection, flags: WizardFlags): Promise<WizardMode> {
if (SETUP_CONTEXT.kind === 'standalone') {
if (flags.mode && flags.mode !== 'compose') {
throw new Error(
`${flags.mode} mode requires a Sim source checkout; run from a cloned Sim repository or choose --mode compose.`
)
}
return 'compose'
}
if (flags.mode) return flags.mode
const { dockerMemGb } = detection.specs
const vm = dockerMemGb !== null ? ` · VM ${dockerMemGb}GB` : ''
@@ -127,7 +136,7 @@ export async function runWizard(flags: WizardFlags): Promise<void> {
)
if ((await handleExistingConfig(detection)) === 'doctor') {
const { runDoctor } = await import('./doctor.ts')
const { runDoctor } = await import('./doctor')
process.exitCode = await runDoctor({ fix: false, json: false })
return
}
@@ -163,10 +172,9 @@ 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 · update · status · logs',
'check your setup: bun run sim doctor',
'manage it: npx @sim/setup start · stop · update · status · logs',
'check your setup: npx @sim/setup doctor',
mode === 'dev' && !startDevNow ? `start Sim: bun run ${devScript}` : null,
`prefer a bare "sim"? ${theme.command('bun link')} once (needs ~/.bun/bin on PATH)`,
]
.filter(Boolean)
.join('\n'),
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "@sim/tsconfig/base.json",
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
},
})
+1 -1
View File
@@ -8,7 +8,7 @@ import { stripVersionSuffix } from '@sim/utils/string'
*/
import { BLOCK_REGISTRY } from '../apps/sim/blocks/registry-maps'
import { AuthMode, type BlockConfig } from '../apps/sim/blocks/types'
import integrationsJson from '../apps/sim/lib/integrations/integrations.json'
import integrationsJson from '../packages/deployment-config/src/integrations.json'
import { DOCS_ORIGIN, DOCS_OUTPUT_PATH, defaultIntegrationDocsUrl } from './generate-docs'
type CatalogAuthType = 'oauth' | 'api-key' | 'none'

Some files were not shown because too many files have changed in this diff Show More