🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@connectai/selfhost

Package Overview
Dependencies
Maintainers
2
Versions
15
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@connectai/selfhost - npm Package Compare versions

Comparing version
0.1.4
to
0.1.5
+217
assets/docker-compose.selfhost.yml
# 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
}
+48
-100
#!/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
// 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 },

// 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

// 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 }
}

@@ -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'

@@ -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'
}
{
"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