diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ba8f0b5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.git +.env +.env.* +lettabot.yaml +lettabot.yml +lettabot-agent.json +data/ +*.log +.skills/ diff --git a/.gitignore b/.gitignore index 97a3e92..8078ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,9 @@ data/telegram-mtproto/ # Config with secrets lettabot.yaml lettabot.yml + +# Platform-specific deploy configs (generated by fly launch, etc.) +fly.toml bun.lock .tool-versions diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dabec92 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:22-slim AS build +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:22-slim +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY --from=build /app/dist ./dist + +ENV NODE_ENV=production +EXPOSE 8080 + +CMD ["node", "dist/main.js"] diff --git a/README.md b/README.md index 26e3e21..31d9505 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,10 @@ By default, the agent is restricted to **read-only** operations: LettaBot supports pairing-based access control. When `TELEGRAM_DM_POLICY=pairing`: 1. Unauthorized users get a pairing code -2. You approve codes via `lettabot pairing approve telegram ` +2. Approve codes via: + - **Web portal** at `https://your-host/portal` (recommended for cloud deploys) + - **CLI**: `lettabot pairing approve telegram ` + - **API**: `POST /api/v1/pairing/telegram/approve` 3. Approved users can then chat with the bot ## Development diff --git a/docs/README.md b/docs/README.md index 6d98959..c665806 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,8 @@ LettaBot is a multi-channel AI assistant powered by [Letta](https://letta.com) t ## Guides - [Getting Started](./getting-started.md) - Installation and basic setup -- [Docker Server Setup](./selfhosted-setup.md) - Run with your own Letta server +- [Cloud Deployment](./cloud-deploy.md) - Deploy to Fly.io, Railway, Docker, or any cloud platform +- [Self-Hosted Letta Server](./selfhosted-setup.md) - Run with your own Letta server (instead of Letta API) - [Configuration Reference](./configuration.md) - All config options - [Commands Reference](./commands.md) - Bot commands reference - [CLI Tools](./cli-tools.md) - Agent/operator CLI tools @@ -14,7 +15,7 @@ LettaBot is a multi-channel AI assistant powered by [Letta](https://letta.com) t - [Response Directives](./directives.md) - XML action directives (reactions, etc.) - [Scheduling Tasks](./cron-setup.md) - Cron jobs and heartbeats - [Gmail Pub/Sub](./gmail-pubsub.md) - Email notifications integration -- [Railway Deployment](./railway-deploy.md) - Deploy to Railway +- [Railway Deployment](./railway-deploy.md) - Railway-specific setup (one-click deploy, volumes) - [Releasing](./releasing.md) - How to create releases ### Channel Setup diff --git a/docs/cloud-deploy.md b/docs/cloud-deploy.md new file mode 100644 index 0000000..35b32ee --- /dev/null +++ b/docs/cloud-deploy.md @@ -0,0 +1,155 @@ +# Cloud Deployment + +Deploy LettaBot to any cloud platform that supports Docker or Node.js. + +## Prerequisites + +- A [Letta API key](https://app.letta.com) (or a self-hosted Letta server -- see [Docker Server Setup](./selfhosted-setup.md)) +- At least one channel token (Telegram, Discord, or Slack) +- A working `lettabot.yaml` config (run `lettabot onboard` to create one) + +## Configuration + +Cloud platforms typically don't support config files directly. LettaBot solves this with `LETTABOT_CONFIG_YAML` -- a single environment variable containing your entire config. + +### Encoding Your Config + +```bash +# Using the CLI helper (recommended) +lettabot config encode + +# Or manually +base64 < lettabot.yaml | tr -d '\n' +``` + +Set the output as `LETTABOT_CONFIG_YAML` on your platform. This is the only env var you need -- everything (API key, channels, features) is in the YAML. + +Both base64-encoded and raw YAML values are accepted. Base64 is recommended since some platforms don't handle multi-line env vars well. + +### Verifying + +To decode and inspect what a `LETTABOT_CONFIG_YAML` value contains: + +```bash +LETTABOT_CONFIG_YAML=... lettabot config decode +``` + +## Docker + +LettaBot includes a Dockerfile for containerized deployment. + +### Build and Run + +```bash +docker build -t lettabot . + +docker run -d \ + -e LETTABOT_CONFIG_YAML="$(base64 < lettabot.yaml | tr -d '\n')" \ + -p 8080:8080 \ + lettabot +``` + +### Docker Compose + +```yaml +services: + lettabot: + build: . + ports: + - "8080:8080" + environment: + - LETTABOT_CONFIG_YAML=${LETTABOT_CONFIG_YAML} + restart: unless-stopped +``` + +If running alongside a self-hosted Letta server, see [Docker Server Setup](./selfhosted-setup.md) for the Letta container config. + +## Fly.io + +```bash +# Install CLI +brew install flyctl +fly auth login + +# Launch (detects Dockerfile automatically) +fly launch + +# Set your config +fly secrets set LETTABOT_CONFIG_YAML="$(base64 < lettabot.yaml | tr -d '\n')" + +# Set a stable API key (optional, prevents regeneration across deploys) +fly secrets set LETTABOT_API_KEY=$(openssl rand -hex 32) + +# Deploy +fly deploy +``` + +`fly launch` generates a `fly.toml` with your app name. Edit it to keep the bot running (Fly defaults to stopping idle machines): + +```toml +[http_service] + auto_stop_machines = false + min_machines_running = 1 +``` + +Scale to 1 machine (multiple instances would conflict on channel tokens): + +```bash +fly scale count 1 +``` + +## Railway + +See [Railway Deployment](./railway-deploy.md) for the full guide including one-click deploy, persistent volumes, and Railway-specific configuration. + +The short version: + +1. Fork the repo and connect to Railway +2. Set `LETTABOT_CONFIG_YAML` (or individual env vars for simple setups) +3. Deploy + +## Other Platforms + +Any platform that runs Docker images or Node.js works. Set `LETTABOT_CONFIG_YAML` as an env var and you're done. + +**Render:** Deploy from GitHub, set env var in dashboard. + +**DigitalOcean App Platform:** Use the Dockerfile, set env var in app settings. + +**Any VPS (EC2, Linode, Hetzner):** Build the Docker image and run it, or install Node.js and run `npm start` directly. + +## Web Portal + +LettaBot includes an admin portal at `/portal` for managing pairing approvals from a browser. Navigate to `https://your-host/portal` and enter your API key to: + +- View pending pairing requests across all channels +- Approve users with one click +- Auto-refreshes every 10 seconds + +## API Key + +An API key is auto-generated on first boot and printed in logs. It's required for the web portal and HTTP API endpoints. + +To make it stable across deploys, set `LETTABOT_API_KEY` as an environment variable: + +```bash +# Fly.io +fly secrets set LETTABOT_API_KEY=$(openssl rand -hex 32) + +# Railway / Render / etc. +# Set LETTABOT_API_KEY in the platform's env var UI +``` + +## Health Check + +LettaBot exposes `GET /health` which returns `ok`. Configure your platform's health check to use this endpoint. + +## Channel Limitations + +| Channel | Cloud Support | Notes | +|---------|--------------|-------| +| Telegram | Yes | Full support | +| Discord | Yes | Full support | +| Slack | Yes | Full support | +| WhatsApp | No | Requires local QR code pairing | +| Signal | No | Requires local device registration | diff --git a/docs/configuration.md b/docs/configuration.md index bd47d22..007a347 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,18 +2,36 @@ Complete reference for LettaBot configuration options. -## Config File Locations +## Config Sources -LettaBot checks these locations in order: +LettaBot checks these sources in priority order: -1. `LETTABOT_CONFIG` env var - Explicit path override -2. `./lettabot.yaml` - Project-local (recommended) -3. `./lettabot.yml` - Project-local alternate -4. `~/.lettabot/config.yaml` - User global -5. `~/.lettabot/config.yml` - User global alternate +1. `LETTABOT_CONFIG_YAML` env var - Inline YAML or base64-encoded YAML (recommended for cloud/Docker) +2. `LETTABOT_CONFIG` env var - Explicit file path override +3. `./lettabot.yaml` - Project-local (recommended for local dev) +4. `./lettabot.yml` - Project-local alternate +5. `~/.lettabot/config.yaml` - User global +6. `~/.lettabot/config.yml` - User global alternate -For global installs (`npm install -g`), either: -- Create `~/.lettabot/config.yaml`, or +### Cloud / Docker Deployments + +On platforms where you can't include a config file (Railway, Fly.io, Render, etc.), use `LETTABOT_CONFIG_YAML` to pass your entire config as a single environment variable: + +```bash +# Encode your local config as base64 +lettabot config encode + +# Or manually +base64 < lettabot.yaml | tr -d '\n' +``` + +Set the output as `LETTABOT_CONFIG_YAML` on your platform. Raw YAML is also accepted (for platforms that support multi-line env vars). + +### Local Development + +For local installs, either: +- Create `./lettabot.yaml` in your project, or +- Create `~/.lettabot/config.yaml` for global config, or - Set `export LETTABOT_CONFIG=/path/to/your/config.yaml` ## Example Configuration diff --git a/docs/railway-deploy.md b/docs/railway-deploy.md index 6d385f2..f204810 100644 --- a/docs/railway-deploy.md +++ b/docs/railway-deploy.md @@ -1,6 +1,6 @@ # Railway Deployment -Deploy LettaBot to [Railway](https://railway.app) for always-on hosting. +Deploy LettaBot to [Railway](https://railway.app) for always-on hosting. For other platforms (Fly.io, Docker, Render), see [Cloud Deployment](./cloud-deploy.md). ## One-Click Deploy @@ -11,15 +11,33 @@ Deploy LettaBot to [Railway](https://railway.app) for always-on hosting. **No local setup required.** LettaBot automatically finds or creates your agent by name. -## Environment Variables +## Configuration -### Required +### Option A: Full YAML Config (Recommended) + +Use `LETTABOT_CONFIG_YAML` to pass your entire `lettabot.yaml` as a single base64-encoded environment variable. This gives you access to the full config schema (multi-agent, conversation routing, group policies, etc.) without managing dozens of individual env vars. + +```bash +# Encode your local config +base64 < lettabot.yaml | tr -d '\n' + +# Or use the CLI helper +lettabot config encode +``` + +Set the output as `LETTABOT_CONFIG_YAML` in your Railway service variables. That's it -- no other env vars needed (everything is in the YAML). + +### Option B: Individual Environment Variables + +For simple setups (one channel, basic config), you can use individual env vars instead. + +#### Required | Variable | Description | |----------|-------------| | `LETTA_API_KEY` | Your Letta API key ([get one here](https://app.letta.com)) | -### Channel Configuration (at least one required) +#### Channel Configuration (at least one required) **Telegram:** ``` @@ -39,7 +57,7 @@ SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... ``` -### Optional +#### Optional | Variable | Default | Description | |----------|---------|-------------| @@ -107,23 +125,39 @@ LettaBot automatically detects `RAILWAY_VOLUME_MOUNT_PATH` and uses it for persi ## Remote Pairing Approval -When using `pairing` DM policy on Railway, you can approve new users via the HTTP API instead of the CLI: +When using `pairing` DM policy on a cloud deployment, you need a way to approve new users without CLI access. + +### Web Portal + +Navigate to `https://your-app/portal` to access the admin portal. It provides a UI for managing pairing requests across all channels (Telegram, Discord, Slack). + +You'll need your `LETTABOT_API_KEY` to log in. The key is auto-generated on first boot and printed in logs. Set it as an environment variable for stable access across deploys: + +```bash +# Railway +# Set LETTABOT_API_KEY in service variables + +# Fly.io +fly secrets set LETTABOT_API_KEY=your-key -a your-app +``` + +### API + +You can also approve pairings via the HTTP API: ```bash # List pending pairing requests for a channel curl -H "X-Api-Key: $LETTABOT_API_KEY" \ - https://your-app.railway.app/api/v1/pairing/telegram + https://your-app/api/v1/pairing/telegram # Approve a pairing code curl -X POST \ -H "X-Api-Key: $LETTABOT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"code": "ABCD1234"}' \ - https://your-app.railway.app/api/v1/pairing/telegram/approve + https://your-app/api/v1/pairing/telegram/approve ``` -`LETTABOT_API_KEY` is auto-generated on first boot and printed in logs. Set it as a Railway variable for stable access across deploys. - Alternatively, use `allowlist` DM policy and pre-configure allowed users in environment variables to skip pairing entirely. ## Channel Limitations diff --git a/docs/selfhosted-setup.md b/docs/selfhosted-setup.md index 1b1655f..c7a0e1e 100644 --- a/docs/selfhosted-setup.md +++ b/docs/selfhosted-setup.md @@ -107,6 +107,8 @@ server: ### Docker Compose +Run both the Letta server and LettaBot together: + ```yaml services: letta: @@ -123,15 +125,17 @@ services: depends_on: - letta environment: - - LETTA_BASE_URL=http://letta:8283 - volumes: - - ./lettabot.yaml:/app/lettabot.yaml - - ./lettabot-agent.json:/app/lettabot-agent.json + - LETTABOT_CONFIG_YAML=${LETTABOT_CONFIG_YAML} + # Your YAML config should include: server.baseUrl: http://letta:8283 + ports: + - "8080:8080" volumes: letta-data: ``` +For general Docker and cloud deployment (without a self-hosted Letta server), see [Cloud Deployment](./cloud-deploy.md). + ## Troubleshooting ### Connection Refused diff --git a/src/api/server.test.ts b/src/api/server.test.ts index d9ef7a2..dc9a8dd 100644 --- a/src/api/server.test.ts +++ b/src/api/server.test.ts @@ -210,3 +210,32 @@ describe('POST /api/v1/chat', () => { expect(events.find((e: any) => e.type === 'error').error).toBe('connection lost'); }); }); + +describe('GET /portal', () => { + let server: http.Server; + let port: number; + + beforeAll(async () => { + server = createApiServer(createMockRouter(), { + port: TEST_PORT, + apiKey: TEST_API_KEY, + host: '127.0.0.1', + }); + await new Promise((resolve) => { + if (server.listening) { resolve(); return; } + server.once('listening', resolve); + }); + port = getPort(server); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('serves the pairing portal HTML without requiring an API key', async () => { + const res = await request(port, 'GET', '/portal'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/html'); + expect(res.body).toContain('LettaBot Portal'); + }); +}); diff --git a/src/api/server.ts b/src/api/server.ts index 7fc9761..0d655b3 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -484,6 +484,13 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions): return; } + // Route: GET /portal - Admin portal for pairing approvals + if ((req.url === '/portal' || req.url === '/portal/') && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(portalHtml); + return; + } + // Route: 404 Not Found sendError(res, 404, 'Not found'); }); @@ -569,3 +576,173 @@ function sendError(res: http.ServerResponse, status: number, message: string, fi res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(response)); } + +/** + * Admin portal HTML - self-contained page for pairing approvals + */ +const portalHtml = ` + + + + +LettaBot Portal + + + +
+

LettaBot Portal

+ +
+ + + +
+ + +
+
+ + + +`; + diff --git a/src/cli.ts b/src/cli.ts index 8377f52..f5ee9a3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,10 +8,18 @@ * lettabot configure - Configure settings */ -// Config loaded from lettabot.yaml +// Config loaded from lettabot.yaml (lazily, so debug/help commands can run with broken config) +import type { LettaBotConfig } from './config/index.js'; import { loadAppConfigOrExit, applyConfigToEnv, serverModeLabel } from './config/index.js'; -const config = loadAppConfigOrExit(); -applyConfigToEnv(config); +let cachedConfig: LettaBotConfig | null = null; + +function getConfig(): LettaBotConfig { + if (!cachedConfig) { + cachedConfig = loadAppConfigOrExit(); + applyConfigToEnv(cachedConfig); + } + return cachedConfig; +} import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { getCronStorePath, getDataDir, getLegacyCronStorePath, getWorkingDir } from './utils/paths.js'; @@ -44,6 +52,7 @@ import { onboard } from './onboard.js'; async function configure() { const p = await import('@clack/prompts'); const { resolveConfigPath } = await import('./config/index.js'); + const config = getConfig(); p.intro('🤖 LettaBot Configuration'); @@ -97,21 +106,49 @@ async function configure() { } } +async function configEncode() { + const { resolveConfigPath, encodeConfigForEnv } = await import('./config/index.js'); + const configPath = resolveConfigPath(); + + if (!existsSync(configPath)) { + console.error(`No config file found at ${configPath}`); + process.exit(1); + } + + const content = readFileSync(configPath, 'utf-8'); + const encoded = encodeConfigForEnv(content); + console.log('Set this environment variable on your cloud platform:\n'); + console.log(`LETTABOT_CONFIG_YAML=${encoded}`); + console.log(`\nSource: ${configPath} (${content.length} bytes -> ${encoded.length} chars base64)`); +} + +async function configDecode() { + if (!process.env.LETTABOT_CONFIG_YAML) { + console.error('LETTABOT_CONFIG_YAML is not set'); + process.exit(1); + } + + const { decodeYamlOrBase64 } = await import('./config/index.js'); + console.log(decodeYamlOrBase64(process.env.LETTABOT_CONFIG_YAML)); +} + async function server() { - const { resolveConfigPath } = await import('./config/index.js'); + const { resolveConfigPath, hasInlineConfig } = await import('./config/index.js'); const configPath = resolveConfigPath(); - // Check if configured - if (!existsSync(configPath)) { + // Check if configured (inline config or file) + if (!existsSync(configPath) && !hasInlineConfig()) { console.log(` No config file found. Searched locations: - 1. LETTABOT_CONFIG env var (not set) - 2. ./lettabot.yaml (project-local - recommended) - 3. ./lettabot.yml - 4. ~/.lettabot/config.yaml (user global) - 5. ~/.lettabot/config.yml + 1. LETTABOT_CONFIG_YAML env var (inline YAML or base64 - recommended for cloud) + 2. LETTABOT_CONFIG env var (file path) + 3. ./lettabot.yaml (project-local - recommended for local dev) + 4. ./lettabot.yml + 5. ~/.lettabot/config.yaml (user global) + 6. ~/.lettabot/config.yml -Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG=/path/to/config.yaml +Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG_YAML for cloud deploys. +Encode your config: base64 < lettabot.yaml | tr -d '\\n' `); process.exit(1); } @@ -190,6 +227,8 @@ Commands: onboard Setup wizard (integrations, skills, configuration) server Start the bot server configure View and edit configuration + config encode Encode config file as base64 for LETTABOT_CONFIG_YAML + config decode Decode and print LETTABOT_CONFIG_YAML env var model Interactive model selector model show Show current agent model model set Set model by handle (e.g., anthropic/claude-sonnet-4-5-20250929) @@ -225,6 +264,7 @@ Examples: lettabot pairing approve telegram ABCD1234 # Approve a pairing code Environment: + LETTABOT_CONFIG_YAML Inline YAML or base64-encoded config (for cloud deploys) LETTA_API_KEY API key from app.letta.com TELEGRAM_BOT_TOKEN Bot token from @BotFather TELEGRAM_DM_POLICY DM access policy (pairing, allowlist, open) @@ -239,6 +279,7 @@ Environment: } function getDefaultTodoAgentKey(): string { + const config = getConfig(); const configuredName = (config.agent?.name?.trim()) || (config.agents?.length && config.agents[0].name?.trim()) @@ -255,6 +296,19 @@ function getDefaultTodoAgentKey(): string { } async function main() { + // Most commands expect config-derived env vars to be applied. + // Skip bootstrap for help/no-command and config encode/decode so these still work + // when the current config is broken. + if ( + command && + command !== 'help' && + command !== '-h' && + command !== '--help' && + !(command === 'config' && (subCommand === 'encode' || subCommand === 'decode')) + ) { + getConfig(); + } + switch (command) { case 'onboard': case 'setup': @@ -271,7 +325,13 @@ async function main() { case 'configure': case 'config': - await configure(); + if (subCommand === 'encode') { + await configEncode(); + } else if (subCommand === 'decode') { + await configDecode(); + } else { + await configure(); + } break; case 'skills': { @@ -421,6 +481,7 @@ async function main() { case 'reset-conversation': { const p = await import('@clack/prompts'); + const config = getConfig(); p.intro('Reset Conversation'); diff --git a/src/config/io.ts b/src/config/io.ts index cd5f9c7..b9f0b9e 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1,7 +1,11 @@ /** * LettaBot Configuration I/O * - * Config file location: ~/.lettabot/config.yaml (or ./lettabot.yaml in project) + * Config sources (checked in priority order): + * 1. LETTABOT_CONFIG_YAML env var (inline YAML or base64-encoded YAML) + * 2. LETTABOT_CONFIG env var (file path) + * 3. ./lettabot.yaml or ./lettabot.yml (project-local) + * 4. ~/.lettabot/config.yaml or ~/.lettabot/config.yml (user global) */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; @@ -26,7 +30,52 @@ const CONFIG_PATHS = [ const DEFAULT_CONFIG_PATH = join(homedir(), '.lettabot', 'config.yaml'); /** - * Find the config file path (first existing, or default) + * Whether inline config is available via LETTABOT_CONFIG_YAML env var. + * When set, this takes priority over all file-based config sources. + */ +export function hasInlineConfig(): boolean { + return !!process.env.LETTABOT_CONFIG_YAML; +} + +/** + * Decode a value that may be raw YAML or base64-encoded YAML. + * Detection: if the value contains a colon, it's raw YAML (every valid config + * has key: value pairs). Otherwise it's base64 (which uses only [A-Za-z0-9+/=]). + */ +export function decodeYamlOrBase64(value: string): string { + if (value.includes(':')) { + return value; + } + return Buffer.from(value, 'base64').toString('utf-8'); +} + +/** + * Decode inline config from LETTABOT_CONFIG_YAML env var. + */ +function decodeInlineConfig(): string { + return decodeYamlOrBase64(process.env.LETTABOT_CONFIG_YAML!); +} + +/** + * Human-readable label for where config was loaded from. + */ +export function configSourceLabel(): string { + if (hasInlineConfig()) return 'LETTABOT_CONFIG_YAML'; + const path = resolveConfigPath(); + return existsSync(path) ? path : 'defaults + environment variables'; +} + +/** + * Encode a YAML config file as a base64 string suitable for LETTABOT_CONFIG_YAML. + */ +export function encodeConfigForEnv(yamlContent: string): string { + return Buffer.from(yamlContent, 'utf-8').toString('base64'); +} + +/** + * Find the config file path (first existing, or default). + * Note: when LETTABOT_CONFIG_YAML is set, file-based config is bypassed + * entirely -- use hasInlineConfig() to check. * * Priority: * 1. LETTABOT_CONFIG env var (explicit override) @@ -102,10 +151,24 @@ function parseAndNormalizeConfig(content: string): LettaBotConfig { } /** - * Load config from YAML file + * Load config from inline env var or YAML file */ export function loadConfig(): LettaBotConfig { _lastLoadFailed = false; + + // Inline config takes priority over file-based config + if (hasInlineConfig()) { + try { + const content = decodeInlineConfig(); + return parseAndNormalizeConfig(content); + } catch (err) { + _lastLoadFailed = true; + log.error('Failed to parse LETTABOT_CONFIG_YAML:', err); + log.warn('Using default configuration. Check your YAML syntax.'); + return { ...DEFAULT_CONFIG }; + } + } + const configPath = resolveConfigPath(); if (!existsSync(configPath)) { @@ -129,6 +192,18 @@ export function loadConfig(): LettaBotConfig { */ export function loadConfigStrict(): LettaBotConfig { _lastLoadFailed = false; + + // Inline config takes priority over file-based config + if (hasInlineConfig()) { + try { + const content = decodeInlineConfig(); + return parseAndNormalizeConfig(content); + } catch (err) { + _lastLoadFailed = true; + throw err; + } + } + const configPath = resolveConfigPath(); if (!existsSync(configPath)) { diff --git a/src/config/runtime.test.ts b/src/config/runtime.test.ts index 56f81be..7941355 100644 --- a/src/config/runtime.test.ts +++ b/src/config/runtime.test.ts @@ -73,4 +73,35 @@ describe('loadAppConfigOrExit', () => { rmSync(tmpDir, { recursive: true, force: true }); } }); + + it('should mention LETTABOT_CONFIG_YAML when inline config is invalid', () => { + const originalInline = process.env.LETTABOT_CONFIG_YAML; + const originalPath = process.env.LETTABOT_CONFIG; + + try { + process.env.LETTABOT_CONFIG_YAML = 'server:\n api: port: 6702\n'; + delete process.env.LETTABOT_CONFIG; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exit = (code: number): never => { + throw new Error(`exit:${code}`); + }; + + expect(() => loadAppConfigOrExit(exit)).toThrow('exit:1'); + expect(errorSpy).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('Failed to load LETTABOT_CONFIG_YAML'), + expect.anything() + ); + expect(errorSpy).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('Fix the errors above in LETTABOT_CONFIG_YAML') + ); + + errorSpy.mockRestore(); + } finally { + process.env.LETTABOT_CONFIG_YAML = originalInline; + process.env.LETTABOT_CONFIG = originalPath; + } + }); }); diff --git a/src/config/runtime.ts b/src/config/runtime.ts index b39ecb4..28d007a 100644 --- a/src/config/runtime.ts +++ b/src/config/runtime.ts @@ -1,5 +1,5 @@ import type { LettaBotConfig } from './types.js'; -import { loadConfigStrict, resolveConfigPath } from './io.js'; +import { configSourceLabel, loadConfigStrict } from './io.js'; import { createLogger } from '../logger.js'; @@ -14,9 +14,9 @@ export function loadAppConfigOrExit(exitFn: ExitFn = process.exit): LettaBotConf try { return loadConfigStrict(); } catch (err) { - const configPath = resolveConfigPath(); - log.error(`Failed to load ${configPath}:`, err); - log.error(`Fix the errors above in ${configPath} and restart.`); + const source = configSourceLabel(); + log.error(`Failed to load ${source}:`, err); + log.error(`Fix the errors above in ${source} and restart.`); return exitFn(1); } } diff --git a/src/main.ts b/src/main.ts index c1a762c..7bf0448 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,6 +19,8 @@ import { applyConfigToEnv, syncProviders, resolveConfigPath, + configSourceLabel, + hasInlineConfig, isDockerServerMode, serverModeLabel, } from './config/index.js'; @@ -31,8 +33,7 @@ import { createLogger, setLogLevel } from './logger.js'; const log = createLogger('Config'); const yamlConfig = loadAppConfigOrExit(); -const configSource = existsSync(resolveConfigPath()) ? resolveConfigPath() : 'defaults + environment variables'; -log.info(`Loaded from ${configSource}`); +log.info(`Loaded from ${configSourceLabel()}`); if (yamlConfig.agents?.length) { log.info(`Mode: ${serverModeLabel(yamlConfig.server.mode)}, Agents: ${yamlConfig.agents.map(a => a.name).join(', ')}`); } else { @@ -181,19 +182,21 @@ import { agentExists, findAgentByName, ensureNoToolApprovals } from './tools/let import { isVoiceMemoConfigured } from './skills/loader.js'; // Skills are now installed to agent-scoped location after agent creation (see bot.ts) -// Check if config exists (skip in Railway/Docker where env vars are used directly) +// Check if config exists (skip when inline config, container deploy, or env vars are used) const configPath = resolveConfigPath(); const isContainerDeploy = !!(process.env.RAILWAY_ENVIRONMENT || process.env.RENDER || process.env.FLY_APP_NAME || process.env.DOCKER_DEPLOY); -if (!existsSync(configPath) && !isContainerDeploy) { +if (!existsSync(configPath) && !isContainerDeploy && !hasInlineConfig()) { log.info(` No config file found. Searched locations: - 1. LETTABOT_CONFIG env var (not set) - 2. ./lettabot.yaml (project-local - recommended) - 3. ./lettabot.yml - 4. ~/.lettabot/config.yaml (user global) - 5. ~/.lettabot/config.yml + 1. LETTABOT_CONFIG_YAML env var (inline YAML or base64 - recommended for cloud) + 2. LETTABOT_CONFIG env var (file path) + 3. ./lettabot.yaml (project-local - recommended for local dev) + 4. ./lettabot.yml + 5. ~/.lettabot/config.yaml (user global) + 6. ~/.lettabot/config.yml -Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG=/path/to/config.yaml +Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG_YAML for cloud deploys. +Encode your config: base64 < lettabot.yaml | tr -d '\\n' `); process.exit(1); }