@connectai/selfhost
Advanced tools
| # ConnectAI self-host stack — IMAGE-BASED variant (no source clone). | ||
| # | ||
| # This is the variant bundled by the `@connectai/selfhost` npx CLI. It is | ||
| # identical to the repo-root docker-compose.selfhost.yml EXCEPT that the first- | ||
| # party services run PREBUILT images pulled from GHCR instead of building from a | ||
| # source `context: .`. A clean machine with only Docker + Node never fetches | ||
| # source: `docker compose ... up -d` PULLS the images. | ||
| # | ||
| # CON-258: the bundled installer must default to a KNOWN-PULLABLE tag. The CLI | ||
| # exports ${CONNECTAI_IMAGE_TAG}; the YAML fallback below is pinned to the same | ||
| # verified release tag so a direct `docker compose` invocation stays consistent. | ||
| # | ||
| # Production-faithful: the app talks to a bundled Infisical vault exactly as the | ||
| # managed cloud does (cred_<id> plaintext lives only in Infisical, never in the | ||
| # brain DB). pg-boss is the durable queue and runs INSIDE the app Postgres (its own | ||
| # `pgboss` schema); the Redis below belongs to Infisical only. | ||
| # | ||
| # The CLI automates the first-run order (Infisical up + healthy -> provision | ||
| # identity -> full stack -> API /health + console). See `connectai run` and | ||
| # SELF_HOSTING.md. The `--env-file deploy/selfhost/.env` flag is REQUIRED on every | ||
| # compose call: compose reads that file for ${VAR} interpolation (the infisical | ||
| # ENCRYPTION_KEY / AUTH_SECRET), which `env_file:` on a service does NOT do. | ||
| name: connectai | ||
| services: | ||
| db: | ||
| image: pgvector/pgvector:pg16 | ||
| restart: unless-stopped | ||
| environment: | ||
| POSTGRES_USER: ${POSTGRES_USER:-connectai} | ||
| POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-postgres} | ||
| POSTGRES_DB: ${POSTGRES_DB:-connectai} | ||
| volumes: | ||
| - db-data:/var/lib/postgresql/data | ||
| ports: | ||
| - "5432:5432" | ||
| healthcheck: | ||
| test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-connectai} -d ${POSTGRES_DB:-connectai}"] | ||
| interval: 5s | ||
| timeout: 5s | ||
| retries: 20 | ||
| infisical-db: | ||
| image: postgres:16-alpine | ||
| restart: unless-stopped | ||
| environment: | ||
| POSTGRES_USER: infisical | ||
| POSTGRES_PASSWORD: infisical | ||
| POSTGRES_DB: infisical | ||
| volumes: | ||
| - infisical-db-data:/var/lib/postgresql/data | ||
| healthcheck: | ||
| test: ["CMD-SHELL", "pg_isready -U infisical -d infisical"] | ||
| interval: 5s | ||
| timeout: 5s | ||
| retries: 20 | ||
| infisical-redis: | ||
| image: redis:7-alpine | ||
| restart: unless-stopped | ||
| volumes: | ||
| - infisical-redis-data:/data | ||
| healthcheck: | ||
| test: ["CMD", "redis-cli", "ping"] | ||
| interval: 5s | ||
| timeout: 5s | ||
| retries: 20 | ||
| infisical: | ||
| # Pinned: `latest` drifts the admin-signup API and breaks provisioning. Pin a | ||
| # verified version for a reproducible boot. See SELF_HOSTING.md troubleshooting. | ||
| image: infisical/infisical:v0.159.28 | ||
| restart: unless-stopped | ||
| depends_on: | ||
| infisical-db: | ||
| condition: service_healthy | ||
| infisical-redis: | ||
| condition: service_healthy | ||
| environment: | ||
| NODE_ENV: production | ||
| ENCRYPTION_KEY: ${INFISICAL_ENCRYPTION_KEY} | ||
| AUTH_SECRET: ${INFISICAL_AUTH_SECRET} | ||
| DB_CONNECTION_URI: postgres://infisical:infisical@infisical-db:5432/infisical | ||
| REDIS_URL: redis://infisical-redis:6379 | ||
| SITE_URL: ${INFISICAL_SITE_URL:-http://localhost:8082} | ||
| ports: | ||
| - "8082:8080" | ||
| healthcheck: | ||
| test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8080/api/status || exit 1"] | ||
| interval: 10s | ||
| timeout: 5s | ||
| retries: 30 | ||
| api: | ||
| # PREBUILT image (no build context, no source clone). api + loop-svc share one | ||
| # merged backend image; the role is selected at runtime (api default, worker | ||
| # via the loop-svc command override below). | ||
| image: ghcr.io/connectai-os/connectai-app:${CONNECTAI_IMAGE_TAG:-v0.1.1} | ||
| restart: unless-stopped | ||
| depends_on: | ||
| db: | ||
| condition: service_healthy | ||
| infisical: | ||
| condition: service_healthy | ||
| env_file: | ||
| - deploy/selfhost/.env | ||
| environment: | ||
| # Override the .env DATABASE_URL host with the in-network service name. | ||
| DATABASE_URL: postgresql://${POSTGRES_USER:-connectai}:${POSTGRES_PASSWORD:-change-me-postgres}@db:5432/${POSTGRES_DB:-connectai} | ||
| INFISICAL_SITE_URL: http://infisical:8080 | ||
| # ARM the fail-closed inference privacy guard. Pinned HERE (not just in the | ||
| # .env) because compose `environment:` OVERRIDES env_file: an operator who | ||
| # edits deploy/selfhost/.env cannot accidentally unset it and silently boot | ||
| # in cloud mode with the guard off. | ||
| COBRAIN_DEPLOYMENT_MODE: selfhost | ||
| # Self-host runs FIRST-PARTY LOCAL auth (no Firebase). Pinned here too: the | ||
| # console's local login + the guarded bootstrap surface only exist in | ||
| # AUTH_MODE=local; the boot-time setup token is emitted only in local mode. | ||
| AUTH_MODE: local | ||
| ports: | ||
| - "4000:4000" | ||
| extra_hosts: | ||
| - "host.docker.internal:host-gateway" | ||
| healthcheck: | ||
| # GET /health is the cheap DB-free liveness probe. node is present in the | ||
| # runtime image; use it so we do not depend on curl/wget being installed. | ||
| test: | ||
| - CMD | ||
| - node | ||
| - -e | ||
| - "fetch('http://localhost:4000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" | ||
| interval: 10s | ||
| timeout: 5s | ||
| retries: 12 | ||
| start_period: 60s | ||
| loop-svc: | ||
| image: ghcr.io/connectai-os/connectai-app:${CONNECTAI_IMAGE_TAG:-v0.1.1} | ||
| command: ["/app/app-entrypoint.sh", "worker"] | ||
| restart: unless-stopped | ||
| depends_on: | ||
| db: | ||
| condition: service_healthy | ||
| infisical: | ||
| condition: service_healthy | ||
| # The api owns migrate-on-boot (db:migrate:locked); wait for it to be healthy | ||
| # so the worker never queries a not-yet-migrated schema. | ||
| api: | ||
| condition: service_healthy | ||
| env_file: | ||
| - deploy/selfhost/.env | ||
| environment: | ||
| DATABASE_URL: postgresql://${POSTGRES_USER:-connectai}:${POSTGRES_PASSWORD:-change-me-postgres}@db:5432/${POSTGRES_DB:-connectai} | ||
| INFISICAL_SITE_URL: http://infisical:8080 | ||
| COBRAIN_DEPLOYMENT_MODE: selfhost | ||
| AUTH_MODE: local | ||
| ports: | ||
| - "4100:4100" | ||
| extra_hosts: | ||
| - "host.docker.internal:host-gateway" | ||
| # The standalone operator CONSOLE is the default self-host frontend (ADR-1). The | ||
| # cloud product SPA (apps/web) is deliberately NOT in this stack. | ||
| # | ||
| # PREBUILT-IMAGE NOTE: the console image bakes VITE_API_BASE_URL at image-BUILD | ||
| # time. With a prebuilt image there is no per-boot rebuild, so the baked base is | ||
| # fixed at the localhost default (http://localhost:4000) the image was published | ||
| # with. That is correct for the v1 localhost eval target. Serving the prebuilt | ||
| # console for a REMOTE origin needs runtime API-base injection (an entrypoint that | ||
| # rewrites the origin at container start), which is BackendEngineer's follow-up | ||
| # (CON-130 section 5). v1 = localhost default. | ||
| console: | ||
| image: ghcr.io/connectai-os/connectai-console:${CONNECTAI_IMAGE_TAG:-v0.1.1} | ||
| restart: unless-stopped | ||
| depends_on: | ||
| # Wait for the api to be healthy so the console's first GET /config and the | ||
| # wizard's GET /bootstrap/status resolve instead of erroring on a cold box. | ||
| api: | ||
| condition: service_healthy | ||
| environment: | ||
| # serve binds $PORT inside the container; map host 5273 to it so the URL is | ||
| # stable across dev and self-host. | ||
| PORT: 8080 | ||
| ports: | ||
| - "5273:8080" | ||
| healthcheck: | ||
| test: | ||
| - CMD | ||
| - node | ||
| - -e | ||
| - "fetch('http://localhost:8080/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" | ||
| interval: 10s | ||
| timeout: 5s | ||
| retries: 10 | ||
| start_period: 15s | ||
| # Local, offline inference (text + 1024-dim embeddings). To use a hosted model | ||
| # instead, remove this service and set the BYO path in deploy/selfhost/.env. | ||
| # ConnectAI's billed gateway is forbidden in self-host mode. | ||
| ollama: | ||
| image: ollama/ollama:latest | ||
| restart: unless-stopped | ||
| volumes: | ||
| - ollama-data:/root/.ollama | ||
| # The bundled Ollama is reached by the api/loop-svc over the compose network at | ||
| # http://ollama:11434, so it needs no host port. Publishing 11434 to the host | ||
| # collides with a native `ollama serve` (common on a dev box). Uncomment + | ||
| # remap to a free host port only if you want to reach this Ollama from the host. | ||
| # ports: | ||
| # - "11435:11434" | ||
| volumes: | ||
| db-data: | ||
| infisical-db-data: | ||
| infisical-redis-data: | ||
| ollama-data: |
| // Pull the local inference models into the bundled Ollama. SOFT-FAIL by contract | ||
| // (CON-131 scope item 6): a pull failure WARNS and points at the wizard, it never | ||
| // fails the install. The stack is already healthy at this point; models only affect | ||
| // first-query latency. | ||
| import { spawn } from 'node:child_process' | ||
| import { composeArgs, composeEnv } from './compose.js' | ||
| import { DEFAULT_CHAT_MODEL, EMBED_MODEL } from './constants.js' | ||
| import { ui } from './ui.js' | ||
| function pullOne(dir, model) { | ||
| return new Promise((resolve) => { | ||
| // `docker compose exec -T ollama ollama pull <model>` streams progress to the | ||
| // inherited stdout. -T disables TTY allocation (safe in non-interactive runs). | ||
| const child = spawn('docker', [...composeArgs(dir), 'exec', '-T', 'ollama', 'ollama', 'pull', model], { | ||
| stdio: 'inherit', | ||
| env: composeEnv(), | ||
| }) | ||
| child.on('error', () => resolve(false)) | ||
| child.on('exit', (code) => resolve(code === 0)) | ||
| }) | ||
| } | ||
| // Pull the chat model (override via --model) + the embedding model. Returns a | ||
| // summary; the caller decides messaging. Always resolves (never rejects). | ||
| export async function pullModels(dir, { chatModel = DEFAULT_CHAT_MODEL } = {}) { | ||
| const targets = [chatModel, EMBED_MODEL] | ||
| const results = [] | ||
| for (const model of targets) { | ||
| ui.step(`Pulling model ${ui.bold(model)} into the bundled Ollama (this can take a while).`) | ||
| const ok = await pullOne(dir, model) | ||
| results.push({ model, ok }) | ||
| if (ok) ui.ok(`pulled ${model}`) | ||
| else ui.warn(`could not pull ${model}; you can pull it later from the setup wizard.`) | ||
| } | ||
| return results | ||
| } |
| #!/usr/bin/env bash | ||
| # One-command self-host boot — IMAGE-BASED / NO-SOURCE variant (bundled by the | ||
| # `@connectai/selfhost` npx CLI). This is the boot ENGINE the CLI delegates to. It | ||
| # drives BackendEngineer's canonical docker-compose.selfhost.images.yml (CON-130): | ||
| # prebuilt GHCR images and the console /config.js runtime seam. No `build:` | ||
| # context, no source clone, and no bundled Ollama. | ||
| # `@connectai/selfhost` npx CLI). This is the boot ENGINE the CLI delegates to. | ||
| # It brings the ConnectAI self-host stack to a healthy, console-served, | ||
| # ready-for-the-wizard state by PULLING prebuilt images (no `build:` context, no | ||
| # source clone) and automating the first-run order: | ||
| # | ||
| # Ordering it automates: | ||
| # 1. ensure deploy/selfhost/.env exists (the CLI writes it first; this block is a | ||
| # fallback for a direct invocation outside the CLI) | ||
| # 2. pull images, bring up Infisical, wait healthy | ||
| # 3. provision the Infisical machine identity (provision-infisical.sh) | ||
| # 4. bring up the full stack (api + console come up independently; inference is | ||
| # whatever the operator pointed the env at) | ||
| # 5. wait for the api /health + the console | ||
| # 1. ensure deploy/selfhost/.env exists (the CLI writes it first; this script's | ||
| # block is a fallback for a direct invocation outside the CLI) | ||
| # 2. pull images, bring up Infisical, wait for it to be healthy | ||
| # 3. provision an Infisical machine identity (provision-infisical.sh) | ||
| # 4. bring up the full stack and wait for the api /health + the console | ||
| # | ||
| # Honors these env knobs the CLI sets: | ||
| # CONNECTAI_ENV_FILE absolute path to the .env (the images compose reads it via | ||
| # ${CONNECTAI_ENV_FILE:-deploy/selfhost/.env}) | ||
| # CONNECTAI_IMAGE_TAG the published GHCR tag (default v0.1.0, CON-130) | ||
| # SELFHOST_NO_PULL=1 bring up db + api + console WITHOUT the loop worker | ||
| # SELFHOST_INFERENCE ollama | byo (default ollama, CON-195). `local` / `host` | ||
| # are accepted as backwards-compatible aliases for ollama. | ||
| # Idempotent. No em dashes (CLAUDE.md). | ||
| # Difference from the repo-root up.sh: there is NO `up -d --build console` rebuild | ||
| # step (a prebuilt image has no build context). The console's VITE_API_BASE_URL is | ||
| # baked at image-publish time to the localhost default, which is correct for the v1 | ||
| # localhost eval target. A remote origin needs runtime API-base injection | ||
| # (BackendEngineer follow-up). Idempotent. No em dashes (CLAUDE.md). | ||
| set -euo pipefail | ||
| # ROOT is the materialized working dir (default ~/.connectai). This script lives at | ||
| # ${ROOT}/scripts/selfhost/up.sh, so ../.. resolves to ${ROOT}; the compose file, | ||
| # the .env, and the sibling scripts all hang off it. | ||
| # ROOT is the materialized working dir (default ~/.connectai-selfhost for new | ||
| # packaged installs; older packaged installs may still live at ~/.connectai). This | ||
| # script lives at ${ROOT}/scripts/selfhost/up.sh, so ../.. resolves to ${ROOT}; the | ||
| # compose file, the .env, and the sibling scripts all hang off it. The CLI | ||
| # materializes the bundled assets into this exact layout, so the paths below are | ||
| # clone-free. | ||
| ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" | ||
| COMPOSE_FILE="${ROOT}/docker-compose.selfhost.images.yml" | ||
| ENV_FILE="${CONNECTAI_ENV_FILE:-${ROOT}/deploy/selfhost/.env}" | ||
| COMPOSE_FILE="${ROOT}/docker-compose.selfhost.yml" | ||
| ENV_FILE="${ROOT}/deploy/selfhost/.env" | ||
| ENV_EXAMPLE="${ROOT}/deploy/selfhost/.env.example" | ||
| # The images compose reads ${CONNECTAI_ENV_FILE} for its env_file: and ${...} | ||
| # interpolation; export an ABSOLUTE path so it resolves regardless of cwd. Default | ||
| # the image tag for a direct invocation (the CLI normally sets it). | ||
| export CONNECTAI_ENV_FILE="${ENV_FILE}" | ||
| : "${CONNECTAI_IMAGE_TAG:=v0.1.0}" | ||
| export CONNECTAI_IMAGE_TAG | ||
| # --env-file feeds compose's own ${VAR} interpolation (the infisical ENCRYPTION_KEY | ||
| # / AUTH_SECRET). Keep it on every call. | ||
| # --env-file points compose at deploy/selfhost/.env for ${...} INTERPOLATION (the | ||
| # infisical service's ENCRYPTION_KEY / AUTH_SECRET). Keep this flag on every call. | ||
| # COMPOSE_PROJECT_NAME may be injected by the CLI so `--dir /tmp/...` gets an | ||
| # isolated compose project instead of reusing the default `connectai` stack. | ||
| COMPOSE=(docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}") | ||
| # Inference mode (CON-195). Canonical values are ollama|byo; local|host map to | ||
| # ollama for compatibility with older CLI invocations. | ||
| SELFHOST_INFERENCE="${SELFHOST_INFERENCE:-ollama}" | ||
| case "${SELFHOST_INFERENCE}" in | ||
| local|host) SELFHOST_INFERENCE="ollama" ;; | ||
| esac | ||
| # Force clean, line-based progress output so captured/piped streams do not get the | ||
| # TTY spinner's garbled "80.7ss" frames (CON-195 part 2). Applied to the noisy pull | ||
| # + up calls; kept OFF the COMPOSE base so the printed hint commands stay clean. | ||
| PROGRESS_ARGS=(--progress plain) | ||
| CONSOLE_URL="http://localhost:5273" | ||
| SETUP_URL="${CONSOLE_URL}/setup" | ||
| API_HEALTH_URL="http://localhost:4000/health" | ||
@@ -76,5 +57,6 @@ | ||
| # --- 1. .env (fallback) ------------------------------------------------------ | ||
| # The CLI writes a complete .env (ENCRYPTION_SECRET + a strong POSTGRES_PASSWORD) | ||
| # BEFORE calling this script, so the block below is normally a no-op. It is kept so | ||
| # a direct `up.sh` invocation (e.g. QA running the engine by hand) still boots. | ||
| # The CLI writes a complete .env (including ENCRYPTION_SECRET + a strong | ||
| # POSTGRES_PASSWORD) BEFORE calling this script, so the block below is normally a | ||
| # no-op ("Using existing .env"). It is kept so a direct `up.sh` invocation (e.g. | ||
| # QA running the bundled engine by hand) still boots unattended. | ||
| _set_env() { | ||
@@ -93,19 +75,4 @@ local k="$1" v="$2" | ||
| # Force KEY=VALUE (replace any existing line, else append). Used for the inference | ||
| # provider, which the operator selects via SELFHOST_INFERENCE: the flag is the | ||
| # explicit intent, so it overrides the .env.example default (unlike _set_env, which | ||
| # only fills blanks). Never used for BYO_* secrets (those are left to the CLI/.env). | ||
| _force_env() { | ||
| local k="$1" v="$2" esc_v | ||
| if grep -qE "^${k}=" "${ENV_FILE}" 2>/dev/null; then | ||
| esc_v=$(printf '%s' "${v}" | sed 's/[&#\\]/\\&/g') | ||
| sed -i -E "s#^${k}=.*#${k}=${esc_v}#" "${ENV_FILE}" | ||
| else | ||
| printf '%s=%s\n' "${k}" "${v}" >>"${ENV_FILE}" | ||
| fi | ||
| } | ||
| if [[ ! -f "${ENV_FILE}" ]]; then | ||
| log "Creating ${ENV_FILE} from the example and generating random secrets." | ||
| mkdir -p "$(dirname "${ENV_FILE}")" | ||
| cp "${ENV_EXAMPLE}" "${ENV_FILE}" | ||
@@ -115,3 +82,7 @@ _set_env "INFISICAL_ENCRYPTION_KEY" "$(openssl rand -hex 16)" | ||
| _set_env "OAUTH_STATE_SECRET" "$(openssl rand -hex 32)" | ||
| # The vault-fallback envelope key (32 bytes / 64 hex). Required by the api when | ||
| # the Infisical client is not yet provisioned. The CLI generates this too. | ||
| _set_env "ENCRYPTION_SECRET" "$(openssl rand -hex 32)" | ||
| # A strong Postgres password (replaces the example placeholder) + the matching | ||
| # DATABASE_URL. The CLI does this with a CSPRNG; this is the by-hand fallback. | ||
| _pg="$(openssl rand -hex 24)" | ||
@@ -126,18 +97,2 @@ _set_env "POSTGRES_PASSWORD" "${_pg}" | ||
| # --- 1a. inference provider (fallback for a direct up.sh invocation) ---------- | ||
| # The CLI writes these BEFORE calling this script (env.js applyInferenceEnv), so this | ||
| # is normally a no-op rewrite of identical values. Kept so a hand-run | ||
| # `SELFHOST_INFERENCE=ollama bash up.sh` still configures the provider. BYO_* secrets | ||
| # are NOT written here; set them via the CLI flags or directly in the .env. Seed the | ||
| # external Ollama URL only when it is currently unset so an operator-edited URL wins. | ||
| case "${SELFHOST_INFERENCE}" in | ||
| byo) | ||
| _force_env COBRAIN_INFERENCE_PROVIDER byo | ||
| ;; | ||
| ollama | *) | ||
| _force_env COBRAIN_INFERENCE_PROVIDER ollama | ||
| _set_env OLLAMA_BASE_URL "http://host.docker.internal:11434" | ||
| ;; | ||
| esac | ||
| # --- 1b. origins posture (advisory) ------------------------------------------ | ||
@@ -153,5 +108,5 @@ _env_val() { grep -E "^$1=" "${ENV_FILE}" 2>/dev/null | tail -1 | cut -d= -f2-; } | ||
| # Pull up front so a bad/unreachable image tag fails LOUDLY here (with the tag in | ||
| # the error) instead of mid-boot. Idempotent: present layers are skipped. | ||
| log "Pulling images (prebuilt tag ${CONNECTAI_IMAGE_TAG}, inference=${SELFHOST_INFERENCE}; no local build)." | ||
| "${COMPOSE[@]}" "${PROGRESS_ARGS[@]}" pull | ||
| # the error) instead of mid-boot. Idempotent: already-present layers are skipped. | ||
| log "Pulling images (prebuilt; no local build). First pull downloads several GB." | ||
| "${COMPOSE[@]}" pull | ||
@@ -177,12 +132,7 @@ log "Bringing up the Infisical vault and waiting for it to be healthy." | ||
| # --- 4. full stack ----------------------------------------------------------- | ||
| if [[ "${SELFHOST_NO_PULL:-}" == "1" ]]; then | ||
| # Skip the loop worker. db + api + console still come up healthy. | ||
| log "Bringing up db + api + console only (--no-pull: skipping the loop worker)." | ||
| "${COMPOSE[@]}" "${PROGRESS_ARGS[@]}" up -d db api console | ||
| else | ||
| log "Bringing up the stack for inference=${SELFHOST_INFERENCE} (db, api, loop-svc, console; no bundled Ollama)." | ||
| "${COMPOSE[@]}" "${PROGRESS_ARGS[@]}" up -d | ||
| fi | ||
| log "Bringing up the full stack (db, api, loop-svc, console, ollama)." | ||
| "${COMPOSE[@]}" up -d | ||
| log "Waiting for the API /health (it runs migrations + catalog seed on boot; first boot can take several minutes)." | ||
| # 120 x 5s = 600s of headroom on slow disks. | ||
| for _ in $(seq 1 120); do | ||
@@ -216,21 +166,19 @@ if curl -fsS "${API_HEALTH_URL}" >/dev/null 2>&1; then | ||
| ============================================================================ | ||
| ConnectAI self-host stack is up. (inference: ${SELFHOST_INFERENCE}) | ||
| ConnectAI self-host stack is up. | ||
| Console: ${CONSOLE_URL} | ||
| Setup: ${SETUP_URL} | ||
| API: ${API_HEALTH_URL} | ||
| NEXT STEP: open the console and run the first-run setup wizard. | ||
| NEXT STEP: copy the one-time setup token, then open the setup URL. | ||
| 1. Get the one-time setup token (printed by the api at boot): | ||
| 1. Get the one-time setup token: | ||
| ${COMPOSE[*]} logs api | grep -i 'setup token' | ||
| npx @connectai/selfhost token --dir ${ROOT} | ||
| 2. Open ${CONSOLE_URL}, paste the token, set the admin password, confirm | ||
| inference, connect a source. | ||
| EOF | ||
| 2. Open ${SETUP_URL} and paste the token. | ||
| 3. Create the first administrator, confirm inference, and connect a source. | ||
| cat <<EOF | ||
| Full walkthrough + troubleshooting: SELF_HOSTING.md | ||
| ============================================================================ | ||
| EOF |
+2
-2
| // Materialize the bundled deploy assets into the working dir, preserving the | ||
| // repo-relative layout the boot scripts expect: | ||
| // | ||
| // <dir>/docker-compose.selfhost.images.yml | ||
| // <dir>/docker-compose.selfhost.yml | ||
| // <dir>/deploy/selfhost/.env.example | ||
@@ -21,3 +21,3 @@ // <dir>/scripts/selfhost/{up,provision-infisical,check-origins}.sh | ||
| const FILES = [ | ||
| { rel: 'docker-compose.selfhost.images.yml', mode: 0o644 }, | ||
| { rel: 'docker-compose.selfhost.yml', mode: 0o644 }, | ||
| { rel: 'deploy/selfhost/.env.example', mode: 0o644 }, | ||
@@ -24,0 +24,0 @@ { rel: 'scripts/selfhost/up.sh', mode: 0o755 }, |
+30
-33
| // Boot orchestration for `connectai run`. Delegates the actual stack bring-up to | ||
| // the bundled up.sh (the hardened, idempotent boot ENGINE) rather than | ||
| // re-implementing compose orchestration, then prints the next step. CON-131 scope | ||
| // items 5-7. The installer no longer owns inference downloads (CON-195); it just | ||
| // sets the inference env and starts the stack. | ||
| // re-implementing compose orchestration, then pulls models (soft-fail) and prints | ||
| // the next step. CON-131 scope items 5-7. | ||
| import { spawn } from 'node:child_process' | ||
| import path from 'node:path' | ||
| import { composeEnv } from './compose.js' | ||
| import { CONSOLE_URL, API_HEALTH_URL } from './constants.js' | ||
| import { | ||
| CONSOLE_URL, | ||
| SETUP_URL, | ||
| API_HEALTH_URL, | ||
| DEFAULT_CHAT_MODEL, | ||
| DEFAULT_IMAGE_TAG, | ||
| EMBED_MODEL, | ||
| } from './constants.js' | ||
| import { ui } from './ui.js' | ||
| // Run the bundled up.sh with inherited stdio so the operator sees live boot | ||
| // progress. opts.model -> OLLAMA_MODEL (useful when the operator points the ollama | ||
| // provider at a custom model slug); opts.pull === false -> SELFHOST_NO_PULL=1 | ||
| // (db + api + console only, skip the loop worker); opts.inference -> SELFHOST_INFERENCE | ||
| // (ollama|byo). Resolves to the exit code; never throws. | ||
| export function runUpScript(dir, opts = {}) { | ||
| // progress. Resolves to the exit code; never throws. | ||
| export function runUpScript(dir) { | ||
| const script = path.join(dir, 'scripts', 'selfhost', 'up.sh') | ||
| const env = composeEnv(dir) | ||
| if (opts.model) env.OLLAMA_MODEL = opts.model | ||
| if (opts.pull === false) env.SELFHOST_NO_PULL = '1' | ||
| if (opts.inference) env.SELFHOST_INFERENCE = opts.inference | ||
| return new Promise((resolve) => { | ||
@@ -27,3 +26,3 @@ const child = spawn('bash', [script], { | ||
| cwd: dir, | ||
| env, | ||
| env: composeEnv(dir), | ||
| }) | ||
@@ -35,26 +34,24 @@ child.on('error', () => resolve(127)) | ||
| // Final operator-facing summary: a clean, aligned, captured-safe block with the | ||
| // console URL, the setup-token command, and the next step. `mode` (ollama|byo) | ||
| // tailors the inference line. No secret values. | ||
| export function printNextStep(dir, mode = 'ollama') { | ||
| // Final operator-facing summary: console URL, the setup-token command, and the | ||
| // single next step. | ||
| export function printNextStep(dir) { | ||
| const tokenCmd = `npx @connectai/selfhost token --dir ${dir}` | ||
| const rule = '─'.repeat(64) | ||
| const inferenceNote = | ||
| mode === 'ollama' | ||
| ? 'confirm inference (the endpoint is pointed at your Ollama URL)' | ||
| : 'confirm inference (the endpoint is pointed at your hosted URL)' | ||
| const imageTag = process.env.CONNECTAI_IMAGE_TAG || DEFAULT_IMAGE_TAG | ||
| ui.raw('') | ||
| ui.raw(ui.green(rule)) | ||
| ui.raw(ui.bold(' ConnectAI self-host is up.') + ui.dim(` inference endpoint wired`)) | ||
| ui.raw(ui.bold(ui.green('============================================================'))) | ||
| ui.raw(ui.bold(' ConnectAI self-host is up.')) | ||
| ui.raw('') | ||
| ui.raw(` Console ${ui.cyan(CONSOLE_URL)}`) | ||
| ui.raw(` API ${ui.cyan(API_HEALTH_URL)}`) | ||
| ui.raw(` Installer: ${ui.cyan('npx @connectai/selfhost run')}`) | ||
| ui.raw(` Console: ${ui.cyan(CONSOLE_URL)}`) | ||
| ui.raw(` Setup: ${ui.cyan(SETUP_URL)}`) | ||
| ui.raw(` API: ${ui.cyan(API_HEALTH_URL)}`) | ||
| ui.raw(` Images: ghcr.io/connectai-os/connectai-{app,console}:${imageTag}`) | ||
| ui.raw(` Models: ${DEFAULT_CHAT_MODEL} + ${EMBED_MODEL}`) | ||
| ui.raw('') | ||
| ui.raw(ui.bold(' Next step') + ' open the console and run the first-run wizard:') | ||
| ui.raw(` 1. Get the one-time setup token: ${ui.bold(tokenCmd)}`) | ||
| ui.raw(` 2. Open ${ui.cyan(CONSOLE_URL)}, paste it, set the admin password,`) | ||
| ui.raw(ui.dim(` ${inferenceNote}, then connect a source.`)) | ||
| ui.raw(ui.green(rule)) | ||
| ui.raw(' NEXT STEP: copy the one-time setup token, then open the setup URL.') | ||
| ui.raw(` 1. Get the one-time setup token: ${ui.bold(tokenCmd)}`) | ||
| ui.raw(` 2. Open ${ui.cyan(SETUP_URL)} and paste the token.`) | ||
| ui.raw(' 3. Create the first administrator, confirm inference, and connect a source.') | ||
| ui.raw(ui.bold(ui.green('============================================================'))) | ||
| ui.raw('') | ||
| } |
+66
-117
| // `@connectai/selfhost` CLI entry. Dependency-free arg parsing + command dispatch. | ||
| // | ||
| // Commands: run (default), down, logs, token, help, version | ||
| // Flags: --dir <path> working dir (default ~/.connectai) | ||
| // Flags: --dir <path> working dir (default ~/.connectai-selfhost) | ||
| // --model <name> override the chat model to pull (run only) | ||
@@ -14,8 +14,9 @@ // --no-pull skip model pull (run only) | ||
| import { fileURLToPath } from 'node:url' | ||
| import { DEFAULT_DIR, DEFAULT_CHAT_MODEL } from './constants.js' | ||
| import { DEFAULT_DIR, DEFAULT_CHAT_MODEL, DEFAULT_IMAGE_TAG, LEGACY_DEFAULT_DIR } from './constants.js' | ||
| import { ui } from './ui.js' | ||
| import { runPreflight } from './preflight.js' | ||
| import { materializeAssets } from './assets.js' | ||
| import { ensureEnvFile, applyInferenceEnv } from './env.js' | ||
| import { ensureEnvFile } from './env.js' | ||
| import { runUpScript, printNextStep } from './boot.js' | ||
| import { pullModels } from './models.js' | ||
| import { down, getBootstrapStatus, logs, printSetupToken } from './compose.js' | ||
@@ -29,12 +30,6 @@ | ||
| export function parseArgs(argv) { | ||
| const flags = { | ||
| dir: DEFAULT_DIR, | ||
| model: DEFAULT_CHAT_MODEL, | ||
| pull: true, | ||
| yes: false, | ||
| tag: undefined, | ||
| inference: {}, | ||
| } | ||
| const flags = { dir: DEFAULT_DIR, model: DEFAULT_CHAT_MODEL, pull: true, yes: false, tag: undefined } | ||
| const rest = [] | ||
| let command | ||
| let dirExplicit = false | ||
| for (let i = 0; i < argv.length; i++) { | ||
@@ -49,2 +44,3 @@ const a = argv[i] | ||
| flags.dir = path.resolve(argv[++i] ?? DEFAULT_DIR) | ||
| dirExplicit = true | ||
| break | ||
@@ -57,31 +53,2 @@ case '--model': | ||
| break | ||
| case '--inference-url': | ||
| flags.inference.endpoint = argv[++i] | ||
| break | ||
| case '--inference-api-key': | ||
| flags.inference.apiKey = argv[++i] | ||
| break | ||
| case '--inference-chat-model': | ||
| flags.inference.chatModel = argv[++i] | ||
| break | ||
| case '--inference-embed-model': | ||
| flags.inference.embedModel = argv[++i] | ||
| break | ||
| case '--inference': | ||
| // Backwards-compatible no-op shim: the installer surface is endpoint-first | ||
| // now, so `--inference ollama|byo|host|local` is accepted but ignored. | ||
| i += 1 | ||
| break | ||
| case '--byo-base-url': | ||
| flags.inference.endpoint = argv[++i] | ||
| break | ||
| case '--byo-api-key': | ||
| flags.inference.apiKey = argv[++i] | ||
| break | ||
| case '--byo-chat-model': | ||
| flags.inference.chatModel = argv[++i] | ||
| break | ||
| case '--byo-embed-model': | ||
| flags.inference.embedModel = argv[++i] | ||
| break | ||
| case '--no-pull': | ||
@@ -111,10 +78,11 @@ flags.pull = false | ||
| } | ||
| return { command: command ?? 'run', flags, rest } | ||
| return { command: command ?? 'run', flags, rest, dirExplicit } | ||
| } | ||
| export async function main(argv) { | ||
| const { command, flags, rest } = parseArgs(argv) | ||
| const { command, flags, rest, dirExplicit } = parseArgs(argv) | ||
| if (flags.help && command !== 'help') return printHelp() | ||
| if (flags.tag) process.env.CONNECTAI_IMAGE_TAG = flags.tag | ||
| process.env.CONNECTAI_IMAGE_TAG = flags.tag || process.env.CONNECTAI_IMAGE_TAG || DEFAULT_IMAGE_TAG | ||
| flags.dir = resolveInstallDir(flags.dir, { dirExplicit }) | ||
@@ -142,26 +110,33 @@ switch (command) { | ||
| export function resolveInstallDir( | ||
| requestedDir, | ||
| { dirExplicit = false, fsImpl = fs, uiImpl = ui } = {}, | ||
| ) { | ||
| if (dirExplicit) return requestedDir | ||
| if (isManagedLegacyInstall(LEGACY_DEFAULT_DIR, fsImpl)) { | ||
| uiImpl.info(`reusing legacy packaged install at ${LEGACY_DEFAULT_DIR}.`) | ||
| return LEGACY_DEFAULT_DIR | ||
| } | ||
| return requestedDir | ||
| } | ||
| export function isManagedLegacyInstall(dir, fsImpl = fs) { | ||
| const composePath = path.join(dir, 'docker-compose.selfhost.yml') | ||
| const scriptPath = path.join(dir, 'scripts', 'selfhost', 'up.sh') | ||
| if (!fsImpl.existsSync(composePath) || !fsImpl.existsSync(scriptPath)) return false | ||
| try { | ||
| const compose = fsImpl.readFileSync(composePath, 'utf8') | ||
| const script = fsImpl.readFileSync(scriptPath, 'utf8') | ||
| return ( | ||
| (compose.includes('ghcr.io/connectai-os/connectai-app:${CONNECTAI_IMAGE_TAG') || | ||
| compose.includes('ghcr.io/connectai-os/connectai-api:${CONNECTAI_IMAGE_TAG')) && | ||
| script.includes('IMAGE-BASED / NO-SOURCE variant') | ||
| ) | ||
| } catch { | ||
| return false | ||
| } | ||
| } | ||
| async function cmdRun(flags) { | ||
| ui.banner() | ||
| // Endpoint-first inference config. A key means hosted OpenAI-compatible; no key | ||
| // means Ollama. Generic INFERENCE_* env vars win, with BYO_* accepted as legacy | ||
| // aliases for hosted installs. | ||
| const inference = { | ||
| endpoint: | ||
| flags.inference.endpoint ?? | ||
| process.env.INFERENCE_URL ?? | ||
| process.env.BYO_INFERENCE_BASE_URL, | ||
| apiKey: | ||
| flags.inference.apiKey ?? | ||
| process.env.INFERENCE_API_KEY ?? | ||
| process.env.BYO_INFERENCE_API_KEY, | ||
| chatModel: | ||
| flags.inference.chatModel ?? | ||
| flags.model ?? | ||
| process.env.INFERENCE_CHAT_MODEL ?? | ||
| process.env.BYO_CHAT_MODEL, | ||
| embedModel: | ||
| flags.inference.embedModel ?? | ||
| process.env.INFERENCE_EMBED_MODEL ?? | ||
| process.env.BYO_EMBED_MODEL, | ||
| } | ||
@@ -197,33 +172,6 @@ // 1. Pre-flight. | ||
| // 3b. Inference config (CON-195). Write the provider settings into .env from the | ||
| // operator's endpoint settings. Secret VALUES are never printed (names only). | ||
| ui.step('Configuring inference endpoint.') | ||
| const inf = applyInferenceEnv(flags.dir, inference) | ||
| if (inf.changedKeys.length > 0) { | ||
| ui.ok(`set: ${inf.changedKeys.join(', ')}.`) | ||
| } else { | ||
| ui.ok('inference settings already in place; left them as-is.') | ||
| } | ||
| if (inf.kind === 'ollama') { | ||
| ui.detail('Using an operator-managed inference endpoint (Ollama semantics, no bundled Ollama pulled).') | ||
| } else if (inf.kind === 'byo') { | ||
| ui.detail('Using your hosted OpenAI-compatible endpoint (no bundled Ollama pulled).') | ||
| for (const k of inf.missingHosted) { | ||
| ui.warn(`${k} is not set; the brain cannot answer until you add it to deploy/selfhost/.env.`) | ||
| } | ||
| } | ||
| // 4. Boot (delegate to the bundled up.sh: pull images, vault, provision, stack, | ||
| // wait health). The installer never pulls or starts Ollama itself; --no-pull | ||
| // still brings up db + api + console only. | ||
| if (!flags.pull) { | ||
| ui.step('Booting db + api + console (--no-pull: skipping the loop worker).') | ||
| } else { | ||
| ui.step('Booting the stack (pulling images; inference stays external).') | ||
| } | ||
| const bootCode = await runUpScript(flags.dir, { | ||
| model: flags.model, | ||
| pull: flags.pull, | ||
| inference: inf.kind, | ||
| }) | ||
| // wait health). | ||
| ui.step('Booting the stack (pulling prebuilt images; this is the long step).') | ||
| const bootCode = await runUpScript(flags.dir) | ||
| if (bootCode !== 0) { | ||
@@ -235,4 +183,11 @@ ui.error(`boot failed (up.sh exited ${bootCode}). See the logs above, then:`) | ||
| // 5. Output. | ||
| printNextStep(flags.dir, inf.kind) | ||
| // 5. Model pull (soft-fail). | ||
| if (flags.pull) { | ||
| await pullModels(flags.dir, { chatModel: flags.model }) | ||
| } else { | ||
| ui.step('Skipping model pull (--no-pull). Pull a model later from the wizard.') | ||
| } | ||
| // 6. Output. | ||
| printNextStep(flags.dir) | ||
| return 0 | ||
@@ -251,3 +206,3 @@ } | ||
| } | ||
| const { found, lines } = deps.printSetupToken(flags.dir) | ||
| const { found, token } = deps.printSetupToken(flags.dir) | ||
| if (!found) { | ||
@@ -262,3 +217,3 @@ deps.ui.warn('no setup token found in the api logs yet.') | ||
| } | ||
| for (const l of lines) deps.ui.raw(l.trim()) | ||
| deps.ui.raw(token) | ||
| return 0 | ||
@@ -285,6 +240,9 @@ } | ||
| Canonical package: | ||
| @connectai/selfhost is the npm package name. | ||
| \`connectai\` is only the installed binary alias; do not run \`npx connectai\`. | ||
| Commands: | ||
| run (default) pre-flight, materialize assets, write .env, boot the | ||
| stack, print the | ||
| console URL + next step | ||
| stack, pull local models, print the console URL + next step | ||
| down stop the stack (add -v to also remove volumes / wipe the brain) | ||
@@ -298,21 +256,12 @@ logs tail stack logs (e.g. \`logs api\`) | ||
| Flags: | ||
| --dir <path> working directory (default: ~/.connectai) | ||
| --inference-url <url> inference endpoint URL | ||
| --inference-api-key <key> hosted endpoint API key (if set, installer uses BYO mode) | ||
| --inference-chat-model <m> chat model slug for your inference endpoint | ||
| --inference-embed-model <m> 1024-dim embed model slug for your endpoint | ||
| --model <name> legacy alias for --inference-chat-model (default: ${DEFAULT_CHAT_MODEL}) | ||
| --no-pull skip the loop worker (brings up db + api + console only) | ||
| --tag <tag> GHCR image tag to run (default: the pinned bundled tag) | ||
| --yes, -y non-interactive (already the default; accepted for CI) | ||
| -h, --help this help | ||
| -v, --version version | ||
| --dir <path> working directory (default: ~/.connectai-selfhost) | ||
| --model <name> chat model to pull (default: ${DEFAULT_CHAT_MODEL}) | ||
| --no-pull skip the model pull | ||
| --tag <tag> GHCR image tag to run (default: ${DEFAULT_IMAGE_TAG}) | ||
| --yes, -y non-interactive (already the default; accepted for CI) | ||
| -h, --help this help | ||
| -v, --version version | ||
| (Also reads INFERENCE_* env vars; BYO_* and --inference are accepted as legacy aliases.) | ||
| Examples: | ||
| npx @connectai/selfhost run | ||
| npx @connectai/selfhost run --inference-url http://host.docker.internal:11434 | ||
| npx @connectai/selfhost run \\ | ||
| --inference-url https://your-endpoint.example/v1 --inference-api-key sk-... | ||
| npx @connectai/selfhost run --dir /opt/connectai --no-pull | ||
@@ -319,0 +268,0 @@ npx @connectai/selfhost token |
+34
-10
| // Helpers for invoking `docker compose` against the materialized stack, and the | ||
| // thin pass-through sub-commands (down, logs, token). Centralizes the mandatory | ||
| // `--env-file <dir>/deploy/selfhost/.env -f <dir>/docker-compose.selfhost.images.yml` | ||
| // `--env-file <dir>/deploy/selfhost/.env -f <dir>/docker-compose.selfhost.yml` | ||
| // argv so an operator never types raw compose. | ||
| import { spawn, spawnSync } from 'node:child_process' | ||
| import crypto from 'node:crypto' | ||
| import path from 'node:path' | ||
| import { BOOTSTRAP_STATUS_URL, DEFAULT_IMAGE_TAG, COMPOSE_FILENAME } from './constants.js' | ||
| import { BOOTSTRAP_STATUS_URL, DEFAULT_DIR, DEFAULT_IMAGE_TAG } from './constants.js' | ||
| export function composeFile(dir) { | ||
| return path.join(dir, COMPOSE_FILENAME) | ||
| return path.join(dir, 'docker-compose.selfhost.yml') | ||
| } | ||
@@ -21,6 +22,21 @@ export function envFile(dir) { | ||
| // The env passed to every compose call. Sets the two seams the canonical CON-130 | ||
| // compose reads: CONNECTAI_IMAGE_TAG (pins ghcr.io/...:${tag}) and CONNECTAI_ENV_FILE | ||
| // (the absolute .env path, so env_file: resolves regardless of cwd). Both respect an | ||
| // operator-set override. | ||
| function sanitizeProjectSegment(value) { | ||
| return String(value ?? '') | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9_-]+/g, '-') | ||
| .replace(/^-+|-+$/g, '') | ||
| } | ||
| export function projectNameForDir(dir) { | ||
| const resolved = path.resolve(dir) | ||
| if (resolved === path.resolve(DEFAULT_DIR)) return 'connectai-selfhost' | ||
| const suffix = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8) | ||
| const label = sanitizeProjectSegment(path.basename(resolved)) || 'stack' | ||
| return `connectai-${label}-${suffix}`.slice(0, 63) | ||
| } | ||
| // The env passed to every compose call: the parent env plus the pinned image tag | ||
| // (so the bundled compose resolves ghcr.io/...:${CONNECTAI_IMAGE_TAG}). Respects an | ||
| // operator-set CONNECTAI_IMAGE_TAG override. | ||
| export function composeEnv(dir) { | ||
@@ -30,3 +46,3 @@ return { | ||
| CONNECTAI_IMAGE_TAG: process.env.CONNECTAI_IMAGE_TAG || DEFAULT_IMAGE_TAG, | ||
| CONNECTAI_ENV_FILE: process.env.CONNECTAI_ENV_FILE || (dir ? envFile(dir) : undefined), | ||
| COMPOSE_PROJECT_NAME: process.env.COMPOSE_PROJECT_NAME || projectNameForDir(dir), | ||
| } | ||
@@ -68,2 +84,9 @@ } | ||
| export function extractLatestSetupToken(logOutput) { | ||
| const tokens = extractSetupTokenLines(logOutput) | ||
| .map((line) => line.match(/setup token:\s*([A-Za-z0-9_-]+)/i)?.[1] ?? null) | ||
| .filter(Boolean) | ||
| return tokens.at(-1) ?? null | ||
| } | ||
| export function parseBootstrapStatus(body) { | ||
@@ -108,6 +131,7 @@ try { | ||
| if (res.status !== 0 || !res.stdout) { | ||
| return { found: false, lines: [] } | ||
| return { found: false, token: null, lines: [] } | ||
| } | ||
| const lines = extractSetupTokenLines(res.stdout) | ||
| return { found: lines.length > 0, lines } | ||
| const token = extractLatestSetupToken(res.stdout) | ||
| return { found: Boolean(token), token, lines } | ||
| } |
+19
-21
@@ -5,30 +5,28 @@ // Shared constants for the @connectai/selfhost CLI. | ||
| // The bundled deploy assets. We bundle BackendEngineer's CANONICAL image-based | ||
| // compose (CON-130, merged as docker-compose.selfhost.images.yml) verbatim, so the | ||
| // CLI never ships a divergent fork. Re-sync this file from repo root on a CON-130 | ||
| // change (see assets/README sync note). | ||
| export const COMPOSE_FILENAME = 'docker-compose.selfhost.images.yml' | ||
| // The pinned GHCR image tag for the first-party services (api, loop-svc, console). | ||
| // The CLI exports this as CONNECTAI_IMAGE_TAG so the bundled compose resolves | ||
| // ghcr.io/connectai-os/connectai-*:${CONNECTAI_IMAGE_TAG}. Matches the canonical | ||
| // compose default (CON-130). The GHCR release workflow publishes this tag on a | ||
| // tagged release; override with --tag / the env var to run a different one. | ||
| // The pinned GHCR image tag for the packaged installer's first-party services. | ||
| // The bundle uses one merged backend image (connectai-app) for both api + worker, | ||
| // plus the console image. The CLI exports this as CONNECTAI_IMAGE_TAG so the | ||
| // bundled image-based compose resolves ghcr.io/connectai-os/connectai-*:<tag>. | ||
| // | ||
| // CON-258: the packaged installer must default to a PULLABLE release tag. `latest` | ||
| // was not guaranteed to exist in GHCR, which broke `npx @connectai/selfhost run` | ||
| // before first boot. Keep this pinned to a verified published tag; operators may | ||
| // still override it with --tag / CONNECTAI_IMAGE_TAG. | ||
| export const DEFAULT_IMAGE_TAG = 'v0.1.1' | ||
| // Default chat model surface for an operator-supplied Ollama endpoint. The installer | ||
| // no longer pulls models itself (CON-195), but we still accept --model / OLLAMA_MODEL | ||
| // so the operator can set the slug their remote Ollama serves. | ||
| // CON-94 right-sized local defaults: a small chat model + the 1024-dim embedder. | ||
| export const DEFAULT_CHAT_MODEL = 'qwen2.5:1.5b' | ||
| export const EMBED_MODEL = 'mxbai-embed-large' | ||
| // Default external Ollama URL. The installer ships NO bundled Ollama; when the | ||
| // operator chooses the ollama provider this is the default target, and they can | ||
| // still override OLLAMA_BASE_URL in deploy/selfhost/.env. | ||
| export const DEFAULT_OLLAMA_URL = 'http://host.docker.internal:11434' | ||
| // Default working directory for NEW packaged installs. Keep the npx installer out | ||
| // of the long-lived ~/.connectai namespace used by existing source-based/self-host | ||
| // state on dev machines; packaged installs get their own isolated boundary. | ||
| export const DEFAULT_DIR = path.join(os.homedir(), '.connectai-selfhost') | ||
| // Legacy packaged installs (0.1.4 and earlier) used ~/.connectai. Reuse that path | ||
| // only when it already looks like a packaged install we own. | ||
| export const LEGACY_DEFAULT_DIR = path.join(os.homedir(), '.connectai') | ||
| // Default working directory: where the bundled assets + generated .env live. | ||
| export const DEFAULT_DIR = path.join(os.homedir(), '.connectai') | ||
| // Stable surfaces the boot brings up. | ||
| export const CONSOLE_URL = 'http://localhost:5273' | ||
| export const SETUP_URL = `${CONSOLE_URL}/setup` | ||
| export const API_HEALTH_URL = 'http://localhost:4000/health' | ||
@@ -35,0 +33,0 @@ export const BOOTSTRAP_STATUS_URL = 'http://localhost:4000/bootstrap/status' |
+0
-45
@@ -20,3 +20,2 @@ // .env generation for the self-host stack. | ||
| import path from 'node:path' | ||
| import { planInferenceEnv, missingHostedSettings, resolveInferenceConfig } from './inference.js' | ||
@@ -152,41 +151,2 @@ // Values we treat as "not really set" and therefore safe to fill. An inline | ||
| // Apply the inference settings (CON-195) into dir/deploy/selfhost/.env. The provider | ||
| // line is FORCE-set from the effective endpoint config, and the matching | ||
| // OLLAMA_BASE_URL / BYO_* keys are upserted. Hosted keys are only touched when a | ||
| // value was supplied, so a re-run never blanks a key the operator already set. | ||
| // Returns { changedKeys, kind, missingHosted } with NAMES only — secret VALUES are never returned or | ||
| // logged. Tightens perms (the file holds secrets). Requires the .env to exist | ||
| // already (ensureEnvFile runs first). | ||
| export function applyInferenceEnv(dir, raw = {}) { | ||
| const envPath = path.join(dir, 'deploy', 'selfhost', '.env') | ||
| let text = fs.readFileSync(envPath, 'utf8') | ||
| const inference = resolveInferenceConfig(raw) | ||
| const planned = planInferenceEnv(inference) | ||
| const changes = [] | ||
| for (const c of planned) { | ||
| // Preserve an operator-supplied external Ollama URL. The installer seeds the | ||
| // default host.docker.internal value only when OLLAMA_BASE_URL is currently unset. | ||
| // The one exception is the legacy bundled placeholder (`http://ollama:11434`) | ||
| // from older .env.example files: rewrite THAT stale default to the new external | ||
| // default, but still preserve any real operator-supplied URL. | ||
| if ( | ||
| inference.kind === 'ollama' && | ||
| c.key === 'OLLAMA_BASE_URL' && | ||
| !shouldRewriteOllamaBaseUrl(getEnvValue(text, c.key)) | ||
| ) { | ||
| continue | ||
| } | ||
| text = upsertEnvLine(text, c.key, c.value) | ||
| changes.push(c) | ||
| } | ||
| if (changes.length > 0) fs.writeFileSync(envPath, text, 'utf8') | ||
| try { | ||
| fs.chmodSync(envPath, 0o600) | ||
| } catch { | ||
| /* best-effort on non-POSIX */ | ||
| } | ||
| const missingHosted = missingHostedSettings(inference, (k) => getEnvValue(text, k)) | ||
| return { changedKeys: changes.map((c) => c.key), kind: inference.kind, missingHosted } | ||
| } | ||
| function escapeKey(k) { | ||
@@ -199,6 +159,1 @@ return k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | ||
| } | ||
| function shouldRewriteOllamaBaseUrl(value) { | ||
| if (isUnset(value)) return true | ||
| return String(value).trim() === 'http://ollama:11434' | ||
| } |
+4
-3
| { | ||
| "name": "@connectai/selfhost", | ||
| "version": "0.1.4", | ||
| "description": "One-command self-host installer for ConnectAI. Takes a clean machine (only Docker + Node) to a running, health-checked company-brain in one command, with no source clone and no local image build: `npx @connectai/selfhost run`. Bundles the image-based docker-compose stack + boot scripts, generates a strong .env (CSPRNG ENCRYPTION_SECRET + POSTGRES_PASSWORD), and boots the stack without bundling Ollama.", | ||
| "version": "0.1.5", | ||
| "description": "One-command self-host installer for ConnectAI. Takes a clean machine (only Docker + Node) to a running, health-checked company-brain in one command, with no source clone and no local image build: `npx @connectai/selfhost run`. Bundles the image-based docker-compose stack + boot scripts, generates a strong .env (CSPRNG ENCRYPTION_SECRET + POSTGRES_PASSWORD), boots the stack, and soft-pulls the local inference models.", | ||
| "type": "module", | ||
| "bin": { | ||
| "connectai": "bin/connectai.js" | ||
| "connectai": "bin/connectai.js", | ||
| "selfhost": "bin/connectai.js" | ||
| }, | ||
@@ -9,0 +10,0 @@ "files": [ |
+19
-25
@@ -7,15 +7,17 @@ # @connectai/selfhost | ||
| npx @connectai/selfhost run | ||
| # or: | ||
| # optional global alias: | ||
| npm i -g @connectai/selfhost && connectai run | ||
| ``` | ||
| That is the entire happy path. The CLI pulls prebuilt images, writes a hardened `.env`, boots the stack, and prints the console URL + your single next step. It does **not** bundle or pull Ollama. | ||
| That is the entire happy path. The canonical npm surface is **`@connectai/selfhost`**. The bare word `connectai` is only the installed binary alias after `npm i -g @connectai/selfhost`; do **not** use `npx connectai`. | ||
| The CLI pulls prebuilt images, writes a hardened `.env`, boots the stack, pulls the local inference models, and prints the console URL + your single next step. | ||
| ## What `run` does | ||
| 1. **Pre-flight** (Node-native): verifies a running Docker daemon + Compose v2. Warns (does not block) if RAM/disk is below the ~8 GB footprint. Errors are copy-pasteable. | ||
| 2. **Materialize** the bundled deploy assets into a working dir (default `~/.connectai`, override `--dir`): BackendEngineer's canonical image-based `docker-compose.selfhost.images.yml`, `.env.example`, and the boot scripts. You never fetch source. | ||
| 2. **Materialize** the bundled deploy assets into a working dir (default `~/.connectai-selfhost`, override `--dir`): the image-based `docker-compose.selfhost.yml`, `.env.example`, and the boot scripts. You never fetch source. | ||
| 3. **Generate `.env`**: copies the example, then fills the secrets with a CSPRNG (`crypto.randomBytes`): `INFISICAL_*`, **`ENCRYPTION_SECRET`** (32 bytes / 64 hex), and a strong **`POSTGRES_PASSWORD`** (with `DATABASE_URL` updated to match). Written `chmod 600`. **Idempotent**: it never overwrites a value you already set, and a second run is a no-op. Secrets are local-only: never logged, never transmitted. | ||
| 4. **Boot**: delegates to the bundled `up.sh`, which `docker compose pull`s the prebuilt GHCR images (default tag `v0.1.1`), brings up Infisical, provisions the vault identity, brings up the full stack, and waits for the API `/health` + the console. | ||
| 5. **Inference**: the installer never starts Ollama or downloads models. You point the stack at an inference endpoint with `--inference-url`; if you also provide `--inference-api-key`, the installer wires that endpoint as a hosted OpenAI-compatible provider. Without a key, it treats the endpoint as Ollama and defaults to `http://host.docker.internal:11434` unless you already set `OLLAMA_BASE_URL`. `--model` sets the chat-model slug for the endpoint you supply. `--no-pull` still brings up db + api + console only, so the brain is reachable while you finish inference setup. | ||
| 4. **Boot**: delegates to the bundled `up.sh`, which `docker compose pull`s the prebuilt images, brings up Infisical, provisions the vault identity, brings up the full stack, and waits for the API `/health` + the console. By default the installer runs the first-party GHCR images at the pinned, pullable tag **`v0.1.1`**: the merged backend image `ghcr.io/connectai-os/connectai-app:v0.1.1` (used by both api + worker roles) plus `ghcr.io/connectai-os/connectai-console:v0.1.1`. You can override the release with `--tag` / `CONNECTAI_IMAGE_TAG`. It also pulls the pinned support images from the compose bundle: `infisical/infisical:v0.159.28`, `pgvector/pgvector:pg16`, `postgres:16-alpine`, `redis:7-alpine`, and `ollama/ollama:latest`. | ||
| 5. **Pull models** (`qwen2.5:1.5b` chat + `mxbai-embed-large` embed) into the bundled Ollama. **Soft-fail**: a pull error warns and points at the wizard, it never fails the install. | ||
| 6. **Print** the console URL, the setup-token command, and the next step. | ||
@@ -30,3 +32,3 @@ | ||
| | `logs` | tail stack logs (e.g. `connectai logs api`) | | ||
| | `token` | print the one-time first-run setup token (from the api logs) | | ||
| | `token` | print the one-time first-run setup token, or tell you the instance is already configured | | ||
| | `help` / `version` | usage / version | | ||
@@ -38,20 +40,8 @@ | ||
| | --- | --- | | ||
| | `--dir <path>` | working directory (default `~/.connectai`) | | ||
| | `--inference-url <url>` | inference endpoint URL | | ||
| | `--inference-api-key <key>` | hosted endpoint API key; when set, the installer wires hosted/BYO inference | | ||
| | `--inference-chat-model`, `--inference-embed-model` | model slugs for the endpoint | | ||
| | `--model <name>` | legacy alias for the chat-model slug (default `qwen2.5:1.5b`) | | ||
| | `--no-pull` | skip the loop worker (brings up db + api + console only) | | ||
| | `--tag <tag>` | GHCR image tag to run (default: the pinned bundled tag; also `CONNECTAI_IMAGE_TAG`) | | ||
| | `--dir <path>` | working directory (default `~/.connectai-selfhost`) | | ||
| | `--model <name>` | chat model to pull (default `qwen2.5:1.5b`) | | ||
| | `--no-pull` | skip the model pull | | ||
| | `--tag <tag>` | GHCR image tag to run (default: `v0.1.1`; also `CONNECTAI_IMAGE_TAG`) | | ||
| | `--yes`, `-y` | non-interactive (already the default; accepted for CI) | | ||
| ## Inference modes | ||
| Inference is external by design. The installer ships no Ollama image and downloads no models. | ||
| - **Ollama**: pass `--inference-url http://host.docker.internal:11434`, or omit it and let the installer seed that default when `OLLAMA_BASE_URL` is still unset. | ||
| - **Hosted OpenAI-compatible**: pass `--inference-url ... --inference-api-key ...` and, optionally, the model slugs. The installer derives the internal BYO wiring from the presence of the key; you do not need a separate mode flag. | ||
| Under either mode, `docker images` shows **no** `ollama/ollama`, because the installer never declares an Ollama service. | ||
| ## Requirements | ||
@@ -65,5 +55,9 @@ | ||
| - **localhost is the v1 target.** The prebuilt console image bakes its API base URL at publish time to `http://localhost:4000`. The canonical compose ships a runtime API-base seam (the console entrypoint writes `/config.js` from `CONSOLE_API_BASE_URL` at container start and prefers it over the baked value), so a remote origin is a config change, not a rebuild; full remote serving is the documented follow-up. | ||
| - The first run downloads several GB of service images and can take a few minutes; re-runs are fast and idempotent. | ||
| - This is a thin front door over the same hardened `up.sh` boot engine and the canonical `docker-compose.selfhost.images.yml`; it does not re-implement orchestration or fork the compose. The bundled compose is BackendEngineer's file verbatim (CON-130), consumed through the `${CONNECTAI_IMAGE_TAG}` / `${CONNECTAI_ENV_FILE}` seams. | ||
| - `--dir` is also the compose-project boundary. The default dir keeps the stable `connectai-selfhost` project name; any other dir gets its own derived project name so `down --dir <that-dir>` only tears down that install. | ||
| - New packaged installs default to `~/.connectai-selfhost` so they do not attach to an existing `~/.connectai` checkout or self-host state. If you already installed an older packaged release into `~/.connectai`, the CLI detects that layout and reuses it on upgrade. | ||
| - Only one localhost stack can be up at a time because the services still bind fixed host ports (`4000`, `5273`, `8082`, `5432`). For a clean rerun on a shared box, stop the currently running stack without `-v`, run the temp-dir install, then tear down the temp-dir stack with `down -v`. | ||
| - **localhost is the v1 target.** The prebuilt console image bakes its API base URL at publish time to `http://localhost:4000`, which is correct for the localhost eval. Serving the prebuilt console for a remote origin needs runtime API-base injection (a documented follow-up). | ||
| - The packaged installer defaults to the verified `v0.1.1` release tag. If you override it, keep the same tag across your operator docs and rollout notes. | ||
| - The first run downloads several GB (images + models) and can take a few minutes; re-runs are fast and idempotent. | ||
| - This is a thin front door over the same hardened `up.sh` boot engine used by a source checkout; it does not re-implement orchestration. | ||
@@ -70,0 +64,0 @@ ## License |
| # ConnectAI self-host stack, IMAGE-BASED variant. Identical runtime contract to | ||
| # docker-compose.selfhost.yml, but the first-party services reference PREBUILT, | ||
| # PUBLISHED images instead of `build:` blocks. This is the no-source boot path: on a | ||
| # box with ZERO ConnectAI source, a single | ||
| # | ||
| # docker compose -f docker-compose.selfhost.images.yml --env-file <.env> up -d | ||
| # | ||
| # PULLS every image (never builds) and reaches a healthy API + console. Inference is | ||
| # NOT bundled (CON-195): no Ollama image, no model download. The operator points the | ||
| # stack at their own inference endpoint (host Ollama or a hosted endpoint) via the | ||
| # --env-file. This is what lets the `npx` installer (DXEngineer, CON-127) wrap the | ||
| # stack without a clone or a local build. | ||
| # | ||
| # This file is intentionally SELF-CONTAINED (a full compose, not a `-f base -f | ||
| # overlay` pair) precisely because the overlay form would require the base compose | ||
| # file on disk, which defeats "no source on the machine". The only inputs are this | ||
| # one file plus the --env-file. | ||
| # | ||
| # CON-194: the api and loop-svc services share ONE merged backend image | ||
| # (connectai-app). The runtime role (api | worker) is selected by | ||
| # /app/app-entrypoint.sh, so a no-source box pulls one backend image instead of | ||
| # separate api + worker images. | ||
| # | ||
| # Image tag: the first-party images pin ${CONNECTAI_IMAGE_TAG} (default v0.1.1), | ||
| # produced + pushed on tagged release. Pin a different published tag with | ||
| # CONNECTAI_IMAGE_TAG=vX.Y.Z in the --env-file. The infra images (pgvector, | ||
| # infisical, redis) are already published upstream and unchanged from | ||
| # docker-compose.selfhost.yml. | ||
| # | ||
| # Public images, fail-closed by license: the runtime license guard (CON-34/CON-50) | ||
| # fails CLOSED regardless of who pulls the image, so public images give away nothing | ||
| # licensed. See SELF_HOSTING.md "Image-based boot". | ||
| # | ||
| # Console API base: a prebuilt console image cannot rebuild its baked | ||
| # VITE_API_BASE_URL per boot (that is a Vite BUILD arg). v1 target is the localhost | ||
| # eval, so the published console bakes http://localhost:4000 and serves it directly. | ||
| # A runtime API-base injection seam (entrypoint writes /config.js, the console | ||
| # prefers it over the baked value) ships in the image so a prebuilt console can | ||
| # later target a remote origin without a rebuild: set CONSOLE_API_BASE_URL in the | ||
| # --env-file. Full remote serving is the CON-130 follow-up; localhost is the default. | ||
| # | ||
| # No em dashes (CLAUDE.md). | ||
| name: connectai | ||
| services: | ||
| db: | ||
| image: pgvector/pgvector:pg16 | ||
| restart: unless-stopped | ||
| environment: | ||
| POSTGRES_USER: ${POSTGRES_USER:-connectai} | ||
| POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-postgres} | ||
| POSTGRES_DB: ${POSTGRES_DB:-connectai} | ||
| volumes: | ||
| - db-data:/var/lib/postgresql/data | ||
| ports: | ||
| - "5432:5432" | ||
| healthcheck: | ||
| test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-connectai} -d ${POSTGRES_DB:-connectai}"] | ||
| interval: 5s | ||
| timeout: 5s | ||
| retries: 20 | ||
| infisical-db: | ||
| image: postgres:16-alpine | ||
| restart: unless-stopped | ||
| environment: | ||
| POSTGRES_USER: infisical | ||
| POSTGRES_PASSWORD: infisical | ||
| POSTGRES_DB: infisical | ||
| volumes: | ||
| - infisical-db-data:/var/lib/postgresql/data | ||
| healthcheck: | ||
| test: ["CMD-SHELL", "pg_isready -U infisical -d infisical"] | ||
| interval: 5s | ||
| timeout: 5s | ||
| retries: 20 | ||
| infisical-redis: | ||
| image: redis:7-alpine | ||
| restart: unless-stopped | ||
| volumes: | ||
| - infisical-redis-data:/data | ||
| healthcheck: | ||
| test: ["CMD", "redis-cli", "ping"] | ||
| interval: 5s | ||
| timeout: 5s | ||
| retries: 20 | ||
| infisical: | ||
| # Pinned (see docker-compose.selfhost.yml): `latest` drifts the admin-signup API. | ||
| image: infisical/infisical:v0.159.28 | ||
| restart: unless-stopped | ||
| depends_on: | ||
| infisical-db: | ||
| condition: service_healthy | ||
| infisical-redis: | ||
| condition: service_healthy | ||
| environment: | ||
| NODE_ENV: production | ||
| ENCRYPTION_KEY: ${INFISICAL_ENCRYPTION_KEY} | ||
| AUTH_SECRET: ${INFISICAL_AUTH_SECRET} | ||
| DB_CONNECTION_URI: postgres://infisical:infisical@infisical-db:5432/infisical | ||
| REDIS_URL: redis://infisical-redis:6379 | ||
| SITE_URL: ${INFISICAL_SITE_URL:-http://localhost:8082} | ||
| ports: | ||
| - "8082:8080" | ||
| healthcheck: | ||
| test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8080/api/status || exit 1"] | ||
| interval: 10s | ||
| timeout: 5s | ||
| retries: 30 | ||
| # CON-195: inference is NOT bundled. The installer ships no Ollama and pulls no | ||
| # models. Point the stack at YOUR inference endpoint in deploy/selfhost/.env: | ||
| # - your own Ollama on the host: OLLAMA_BASE_URL=http://host.docker.internal:11434 | ||
| # (the installer's default; COBRAIN_INFERENCE_PROVIDER=ollama, no key) | ||
| # - a hosted OpenAI-compatible endpoint: COBRAIN_INFERENCE_PROVIDER=byo + BYO_*. | ||
| # The api/loop-svc carry extra_hosts: host.docker.internal:host-gateway so the host | ||
| # Ollama default resolves on Linux engines too. | ||
| api: | ||
| # CON-194: the merged api+worker image. The `api` role (default entrypoint) | ||
| # owns migrate-on-boot. Shared with loop-svc below. | ||
| image: ghcr.io/connectai-os/connectai-app:${CONNECTAI_IMAGE_TAG:-v0.1.1} | ||
| restart: unless-stopped | ||
| depends_on: | ||
| db: | ||
| condition: service_healthy | ||
| infisical: | ||
| condition: service_healthy | ||
| env_file: | ||
| # No-source layout: the installer (DXEngineer CLI) sets CONNECTAI_ENV_FILE to | ||
| # wherever it wrote the env; from a source checkout the default matches the | ||
| # source compose. Compose resolves this from the --env-file passed on the CLI. | ||
| - ${CONNECTAI_ENV_FILE:-deploy/selfhost/.env} | ||
| environment: | ||
| DATABASE_URL: postgresql://${POSTGRES_USER:-connectai}:${POSTGRES_PASSWORD:-change-me-postgres}@db:5432/${POSTGRES_DB:-connectai} | ||
| INFISICAL_SITE_URL: http://infisical:8080 | ||
| # ARM the fail-closed inference privacy guard (pinned here so editing the .env | ||
| # cannot silently boot in cloud mode with the guard off). See the source compose. | ||
| COBRAIN_DEPLOYMENT_MODE: selfhost | ||
| AUTH_MODE: local | ||
| # CON-195: lets the default inference target (the operator's host Ollama at | ||
| # host.docker.internal:11434) resolve on Linux engines too (Docker Desktop | ||
| # resolves it natively; host-gateway adds it for Linux). Harmless for a hosted BYO | ||
| # endpoint. | ||
| extra_hosts: | ||
| - "host.docker.internal:host-gateway" | ||
| ports: | ||
| - "4000:4000" | ||
| healthcheck: | ||
| test: | ||
| - CMD | ||
| - node | ||
| - -e | ||
| - "fetch('http://localhost:4000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" | ||
| interval: 10s | ||
| timeout: 5s | ||
| retries: 12 | ||
| start_period: 60s | ||
| loop-svc: | ||
| # CON-194: SAME merged image as api. The `worker` role boots the pg-boss ingest | ||
| # loop and does NOT migrate (api owns migrate-on-boot; running it here too races). | ||
| image: ghcr.io/connectai-os/connectai-app:${CONNECTAI_IMAGE_TAG:-v0.1.1} | ||
| command: ["/app/app-entrypoint.sh", "worker"] | ||
| restart: unless-stopped | ||
| depends_on: | ||
| db: | ||
| condition: service_healthy | ||
| infisical: | ||
| condition: service_healthy | ||
| # api owns migrate-on-boot; wait for it so the worker never queries a | ||
| # not-yet-migrated schema. | ||
| api: | ||
| condition: service_healthy | ||
| # CON-195: no bundled model-pull to gate on. The operator's inference endpoint | ||
| # (host Ollama or a hosted endpoint) is expected to already have its models, so | ||
| # the ingest worker starts as soon as the api is healthy. | ||
| env_file: | ||
| # No-source layout: the installer (DXEngineer CLI) sets CONNECTAI_ENV_FILE to | ||
| # wherever it wrote the env; from a source checkout the default matches the | ||
| # source compose. Compose resolves this from the --env-file passed on the CLI. | ||
| - ${CONNECTAI_ENV_FILE:-deploy/selfhost/.env} | ||
| environment: | ||
| DATABASE_URL: postgresql://${POSTGRES_USER:-connectai}:${POSTGRES_PASSWORD:-change-me-postgres}@db:5432/${POSTGRES_DB:-connectai} | ||
| INFISICAL_SITE_URL: http://infisical:8080 | ||
| COBRAIN_DEPLOYMENT_MODE: selfhost | ||
| AUTH_MODE: local | ||
| # CON-195: see api. The loop worker routes ingest inference through the same | ||
| # endpoint, so it needs host.docker.internal for the host-Ollama default on Linux. | ||
| extra_hosts: | ||
| - "host.docker.internal:host-gateway" | ||
| ports: | ||
| - "4100:4100" | ||
| console: | ||
| image: ghcr.io/connectai-os/connectai-console:${CONNECTAI_IMAGE_TAG:-v0.1.1} | ||
| restart: unless-stopped | ||
| depends_on: | ||
| api: | ||
| condition: service_healthy | ||
| environment: | ||
| # serve binds $PORT inside the container; map host 5273 to it. | ||
| PORT: 8080 | ||
| # Runtime API-base injection seam (CON-130 §5). The published console bakes | ||
| # http://localhost:4000 at build time; the entrypoint writes /config.js from | ||
| # CONSOLE_API_BASE_URL at container start and the console PREFERS it over the | ||
| # baked value. Unset (the v1 localhost default) => the entrypoint emits an empty | ||
| # config and the baked localhost base is used. Set it to retarget a prebuilt | ||
| # console at a remote API origin with NO rebuild. | ||
| CONSOLE_API_BASE_URL: ${CONSOLE_API_BASE_URL:-} | ||
| ports: | ||
| - "5273:8080" | ||
| healthcheck: | ||
| test: | ||
| - CMD | ||
| - node | ||
| - -e | ||
| - "fetch('http://localhost:8080/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" | ||
| interval: 10s | ||
| timeout: 5s | ||
| retries: 10 | ||
| start_period: 15s | ||
| volumes: | ||
| db-data: | ||
| infisical-db-data: | ||
| infisical-redis-data: |
| // Inference config for `connectai run` (CON-195). The installer surface is | ||
| // endpoint-first: the operator points the stack at an inference URL, and the CLI | ||
| // derives the runtime provider wiring beneath that. | ||
| // | ||
| // Rules: | ||
| // - no API key => treat the endpoint as Ollama (`OLLAMA_BASE_URL`) | ||
| // - API key present => treat the endpoint as a hosted OpenAI-compatible provider | ||
| // (`BYO_INFERENCE_*`) | ||
| // - no endpoint supplied => seed the default external Ollama URL | ||
| // | ||
| // The functions here are PURE (no fs, no process): config normalization, provider | ||
| // derivation, and env-key planning. The side-effecting writer lives in env.js so | ||
| // this stays unit-testable without disk/argv. | ||
| import { DEFAULT_OLLAMA_URL } from './constants.js' | ||
| export const DEFAULT_INFERENCE_KIND = 'ollama' | ||
| export function resolveInferenceConfig(raw = {}) { | ||
| const endpoint = normalize(raw.endpoint) | ||
| const apiKey = normalize(raw.apiKey) | ||
| const chatModel = normalize(raw.chatModel) | ||
| const embedModel = normalize(raw.embedModel) | ||
| const kind = apiKey ? 'byo' : 'ollama' | ||
| return { | ||
| kind, | ||
| endpoint: endpoint ?? (kind === 'ollama' ? DEFAULT_OLLAMA_URL : undefined), | ||
| apiKey, | ||
| chatModel, | ||
| embedModel, | ||
| } | ||
| } | ||
| // Plan the .env keys the effective inference config needs. Pure: returns an ordered | ||
| // list of { key, value, reason, secret } upserts. The caller (env.js) applies them | ||
| // with the idempotent upsert and prints NAMES only (secret values are never logged). | ||
| // | ||
| // Hosted keys are included ONLY when a value was supplied, so a re-run without the | ||
| // flags never blanks a value the operator already put in .env. | ||
| export function planInferenceEnv(raw = {}) { | ||
| const inference = resolveInferenceConfig(raw) | ||
| const changes = [] | ||
| const add = (key, value, reason, secret = false) => | ||
| changes.push({ key, value, reason, secret }) | ||
| if (inference.kind === 'ollama') { | ||
| add('COBRAIN_INFERENCE_PROVIDER', 'ollama', 'operator-managed Ollama endpoint') | ||
| add('OLLAMA_BASE_URL', inference.endpoint ?? DEFAULT_OLLAMA_URL, 'external inference URL') | ||
| if (inference.chatModel) add('OLLAMA_MODEL', inference.chatModel, 'your chat model slug') | ||
| } else { | ||
| add('COBRAIN_INFERENCE_PROVIDER', 'byo', 'hosted OpenAI-compatible endpoint') | ||
| if (inference.endpoint) | ||
| add('BYO_INFERENCE_BASE_URL', inference.endpoint, 'your inference base URL') | ||
| if (inference.apiKey) | ||
| add('BYO_INFERENCE_API_KEY', inference.apiKey, 'your endpoint API key', true) | ||
| if (inference.chatModel) add('BYO_CHAT_MODEL', inference.chatModel, 'your chat model slug') | ||
| if (inference.embedModel) | ||
| add('BYO_EMBED_MODEL', inference.embedModel, 'your 1024-dim embed model slug') | ||
| } | ||
| return changes | ||
| } | ||
| // True when, after a re-run, a hosted endpoint still has no base URL or key set in | ||
| // the effective .env: warn (do not fail) so the operator knows the box will not | ||
| // answer until they fill them. `getValue(key)` reads the current .env value. | ||
| export function missingHostedSettings(raw, getValue) { | ||
| const inference = resolveInferenceConfig(raw) | ||
| if (inference.kind !== 'byo') return [] | ||
| const missing = [] | ||
| if (isBlank(getValue('BYO_INFERENCE_BASE_URL'))) missing.push('BYO_INFERENCE_BASE_URL') | ||
| if (isBlank(getValue('BYO_INFERENCE_API_KEY'))) missing.push('BYO_INFERENCE_API_KEY') | ||
| return missing | ||
| } | ||
| function normalize(v) { | ||
| if (v === undefined || v === null) return undefined | ||
| const s = String(v).trim() | ||
| return s === '' ? undefined : s | ||
| } | ||
| function isBlank(v) { | ||
| if (v === undefined || v === null) return true | ||
| const s = String(v).trim() | ||
| return s === '' || s.startsWith('#') | ||
| } |
Sorry, the diff of this file is not supported yet
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 9 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
9
-40%85476
-9.28%797
-13.18%63
-8.7%