diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml
index cafdca5f2c..4c2da84655 100644
--- a/.github/workflows/cli-release.yml
+++ b/.github/workflows/cli-release.yml
@@ -123,10 +123,49 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Output Homebrew formula values
+ - name: Update Homebrew formula
run: |
- echo "::notice::Update Homebrew formula with these SHA256 values:"
- echo "darwin-arm64: ${{ steps.sha.outputs.darwin_arm64 }}"
- echo "darwin-x64: ${{ steps.sha.outputs.darwin_x64 }}"
- echo "linux-x64: ${{ steps.sha.outputs.linux_x64 }}"
+ python3 << 'EOF'
+ import re
+
+ with open('Formula/inbox-zero.rb', 'r') as f:
+ content = f.read()
+
+ # Update version
+ content = re.sub(r'version "[^"]*"', 'version "${{ steps.version.outputs.version }}"', content)
+
+ # Update SHA256 for darwin-arm64 (first sha256 after "on_arm do")
+ content = re.sub(
+ r'(on_arm do.*?sha256 ")[^"]*(")',
+ r'\g<1>${{ steps.sha.outputs.darwin_arm64 }}\2',
+ content, count=1, flags=re.DOTALL
+ )
+
+ # Update SHA256 for darwin-x64 (first sha256 after "on_intel do" inside on_macos)
+ content = re.sub(
+ r'(on_macos do.*?on_intel do.*?sha256 ")[^"]*(")',
+ r'\g<1>${{ steps.sha.outputs.darwin_x64 }}\2',
+ content, count=1, flags=re.DOTALL
+ )
+
+ # Update SHA256 for linux-x64 (sha256 after "on_linux do")
+ content = re.sub(
+ r'(on_linux do.*?sha256 ")[^"]*(")',
+ r'\g<1>${{ steps.sha.outputs.linux_x64 }}\2',
+ content, count=1, flags=re.DOTALL
+ )
+
+ with open('Formula/inbox-zero.rb', 'w') as f:
+ f.write(content)
+
+ print(content)
+ EOF
+
+ - name: Commit formula update
+ run: |
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
+ git add Formula/inbox-zero.rb
+ git diff --staged --quiet || git commit -m "chore: update Homebrew formula for CLI v${{ steps.version.outputs.version }}"
+ git push origin HEAD:${{ github.ref_name }}
diff --git a/Formula/inbox-zero.rb b/Formula/inbox-zero.rb
index 64df288359..2d4060a48e 100644
--- a/Formula/inbox-zero.rb
+++ b/Formula/inbox-zero.rb
@@ -2,8 +2,8 @@
class InboxZero < Formula
desc "CLI tool for setting up Inbox Zero - AI email assistant"
- homepage "https://getinboxzero.com"
- version "2.21.15"
+ homepage "https://www.getinboxzero.com"
+ version "2.21.16"
license "AGPL-3.0-only"
on_macos do
diff --git a/README.md b/README.md
index 3bd9295def..6bdba99688 100644
--- a/README.md
+++ b/README.md
@@ -77,7 +77,7 @@ To request a feature open a [GitHub issue](https://github.com/elie222/inbox-zero
## Getting Started
-We offer a hosted version of Inbox Zero at [https://getinboxzero.com](https://getinboxzero.com).
+We offer a hosted version of Inbox Zero at [https://getinboxzero.com](https://www.getinboxzero.com).
### Self-Hosting with Docker
@@ -221,7 +221,9 @@ Go to [Microsoft Azure Portal](https://portal.azure.com/) and create a new Azure
3. Click "New registration"
1. Choose a name for your application
- 2. Under "Supported account types" select "Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)"
+ 2. Under "Supported account types" select one of:
+ - **Multitenant (default):** "Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)" - allows any Microsoft account
+ - **Single tenant:** "Accounts in this organizational directory only" - restricts to your organization only
3. Set the Redirect URI:
- Platform: Web
- URL: `http://localhost:3000/api/auth/callback/microsoft` (replace `localhost:3000` with your domain in production)
@@ -234,7 +236,8 @@ Go to [Microsoft Azure Portal](https://portal.azure.com/) and create a new Azure
4. Get your credentials from the `Overview` tab:
1. Copy the "Application (client) ID" → this is your `MICROSOFT_CLIENT_ID`
- 2. Go to "Certificates & secrets" in the left sidebar
+ 2. If using single tenant, copy the "Directory (tenant) ID" → this is your `MICROSOFT_TENANT_ID`
+ 3. Go to "Certificates & secrets" in the left sidebar
- Click "New client secret"
- Add a description and choose an expiry
- Click "Add"
@@ -266,6 +269,7 @@ Go to [Microsoft Azure Portal](https://portal.azure.com/) and create a new Azure
```
MICROSOFT_CLIENT_ID=your_client_id_here
MICROSOFT_CLIENT_SECRET=your_client_secret_here
+ MICROSOFT_TENANT_ID=your_tenant_id_here # Only needed for single tenant, omit for multitenant
```
### LLM Setup
diff --git a/apps/web/app/(app)/admin/config/page.tsx b/apps/web/app/(app)/admin/config/page.tsx
new file mode 100644
index 0000000000..aeaefbd968
--- /dev/null
+++ b/apps/web/app/(app)/admin/config/page.tsx
@@ -0,0 +1,182 @@
+import fs from "node:fs";
+import path from "node:path";
+import { env } from "@/env";
+import { auth } from "@/utils/auth";
+import { isAdmin } from "@/utils/admin";
+import { ErrorPage } from "@/components/ErrorPage";
+import { PageWrapper } from "@/components/PageWrapper";
+import { PageHeader } from "@/components/PageHeader";
+
+export default async function AdminConfigPage() {
+ const session = await auth();
+
+ if (!isAdmin({ email: session?.user.email })) {
+ return (
+
+ );
+ }
+
+ const version = getVersion();
+
+ const info = {
+ version,
+ environment: process.env.NODE_ENV,
+ baseUrl: env.NEXT_PUBLIC_BASE_URL,
+ features: {
+ emailSendEnabled: env.NEXT_PUBLIC_EMAIL_SEND_ENABLED,
+ contactsEnabled: env.NEXT_PUBLIC_CONTACTS_ENABLED,
+ bypassPremiumChecks: env.NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS ?? false,
+ },
+ providers: {
+ google: !!env.GOOGLE_CLIENT_ID,
+ microsoft: !!env.MICROSOFT_CLIENT_ID,
+ microsoftTenantConfigured:
+ !!env.MICROSOFT_TENANT_ID && env.MICROSOFT_TENANT_ID !== "common",
+ },
+ llm: {
+ defaultProvider: env.DEFAULT_LLM_PROVIDER,
+ defaultModel: env.DEFAULT_LLM_MODEL ?? "default",
+ economyProvider: env.ECONOMY_LLM_PROVIDER ?? "not configured",
+ economyModel: env.ECONOMY_LLM_MODEL ?? "not configured",
+ },
+ integrations: {
+ redis: !!env.UPSTASH_REDIS_URL || !!env.REDIS_URL,
+ qstash: !!env.QSTASH_TOKEN,
+ tinybird: !!env.TINYBIRD_TOKEN,
+ sentry: !!env.NEXT_PUBLIC_SENTRY_DSN,
+ posthog: !!env.NEXT_PUBLIC_POSTHOG_KEY,
+ stripe: !!env.STRIPE_SECRET_KEY,
+ lemonSqueezy: !!env.LEMON_SQUEEZY_API_KEY,
+ },
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Section({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+
{children}
+
+ );
+}
+
+function Row({ label, value }: { label: string; value: string | boolean }) {
+ const displayValue =
+ typeof value === "boolean" ? (value ? "Yes" : "No") : value;
+
+ return (
+
+ {label}
+ {displayValue}
+
+ );
+}
+
+// Read version at build time
+function getVersion(): string {
+ try {
+ const versionPath = path.join(process.cwd(), "../../version.txt");
+ return fs.readFileSync(versionPath, "utf-8").trim();
+ } catch {
+ return "unknown";
+ }
+}
diff --git a/apps/web/app/(landing)/login/error/page.tsx b/apps/web/app/(landing)/login/error/page.tsx
index 41d4ced46c..416390813f 100644
--- a/apps/web/app/(landing)/login/error/page.tsx
+++ b/apps/web/app/(landing)/login/error/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Suspense, useEffect } from "react";
import { Button } from "@/components/ui/button";
@@ -14,9 +14,19 @@ import { WELCOME_PATH } from "@/utils/config";
import { CrispChatLoggedOutVisible } from "@/components/CrispChat";
import { getAndClearAuthErrorCookie } from "@/utils/auth-cookies";
-export default function LogInErrorPage() {
+const errorMessages: Record = {
+ email_not_found: {
+ title: "Account Not Authorized",
+ description:
+ "Your account is not authorized to access this application. This may be because your email is not part of the allowed organization. Please contact your administrator or try signing in with a different account.",
+ },
+};
+
+function LoginErrorContent() {
const { data, isLoading, error } = useUser();
const router = useRouter();
+ const searchParams = useSearchParams();
+ const errorCode = searchParams.get("error");
// For some reason users are being sent to this page when logged in
// This will redirect them out of this page to the app
@@ -36,20 +46,35 @@ export default function LogInErrorPage() {
// will redirect to welcome if user is logged in
if (data?.id) return ;
+ const errorInfo = errorCode ? errorMessages[errorCode] : null;
+ const title = errorInfo?.title || "Error Logging In";
+ const supportText = `If this error persists, please use the support chat or email us at ${env.NEXT_PUBLIC_SUPPORT_EMAIL}.`;
+ const description = errorInfo?.description
+ ? `${errorInfo.description} ${supportText}`
+ : `Please try again. ${supportText}`;
+
+ return (
+
+
+ Log In
+
+ }
+ />
+ {/* */}
+
+ );
+}
+
+export default function LogInErrorPage() {
return (
-
-
- Log In
-
- }
- />
- {/* */}
-
+ }>
+
+
diff --git a/apps/web/app/(marketing) b/apps/web/app/(marketing)
index feaad52529..5adac62e12 160000
--- a/apps/web/app/(marketing)
+++ b/apps/web/app/(marketing)
@@ -1 +1 @@
-Subproject commit feaad52529289840a1fb0e5f380a23f3b8381e75
+Subproject commit 5adac62e127fcd3e0c79bf63bf0cd3931441338a
diff --git a/apps/web/app/api/v1/openapi/route.ts b/apps/web/app/api/v1/openapi/route.ts
index 4e13f424f5..1231cae428 100644
--- a/apps/web/app/api/v1/openapi/route.ts
+++ b/apps/web/app/api/v1/openapi/route.ts
@@ -64,7 +64,7 @@ export async function GET(request: NextRequest) {
? [{ url: `${customHost}/api/v1`, description: "Custom host" }]
: []),
{
- url: "https://getinboxzero.com/api/v1",
+ url: "https://www.getinboxzero.com/api/v1",
description: "Production server",
},
{ url: "http://localhost:3000/api/v1", description: "Local development" },
diff --git a/copilot/inbox-zero-ecs/manifest.yml b/copilot/inbox-zero-ecs/manifest.yml
index 33b8445242..ddb1258296 100644
--- a/copilot/inbox-zero-ecs/manifest.yml
+++ b/copilot/inbox-zero-ecs/manifest.yml
@@ -36,7 +36,7 @@ network:
variables: # Pass environment variables as key value pairs.
HOSTNAME: 0.0.0.0
- NEXT_PUBLIC_BASE_URL: # YOUR_DOMAIN, e.g. https://getinboxzero.com (with http or https)
+ NEXT_PUBLIC_BASE_URL: # YOUR_DOMAIN, e.g. https://www.getinboxzero.com (with http or https)
DEFAULT_LLM_PROVIDER:
# Set these secrets at AWS Systems Manager (SSM) Parameter Store for extra security.
diff --git a/packages/cli/README.md b/packages/cli/README.md
index b132446fae..d8a61048fc 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -1,6 +1,6 @@
# @inbox-zero/cli
-CLI tool for setting up [Inbox Zero](https://www.getinboxzero.com) - an open source AI email assistant.
+CLI tool for running [Inbox Zero](https://www.getinboxzero.com) - an open-source AI email assistant.
## Installation
@@ -15,44 +15,87 @@ brew install inbox-zero
Download the binary for your platform from [releases](https://github.com/elie222/inbox-zero/releases) and add to your PATH.
-### From source (via pnpm)
+## Quick Start
-If you've cloned the repository:
+```bash
+# Configure Inbox Zero (interactive)
+inbox-zero setup
+
+# Start Inbox Zero
+inbox-zero start
+
+# Open http://localhost:3000
+```
+
+## Commands
+
+### `inbox-zero setup`
+
+Interactive setup wizard that:
+- Configures OAuth providers (Google/Microsoft)
+- Sets up your LLM provider and API key
+- Configures ports (to avoid conflicts)
+- Generates all required secrets
+
+Configuration is stored in `~/.inbox-zero/`
+
+### `inbox-zero start`
+
+Pulls the latest Docker image and starts all containers:
+- PostgreSQL database
+- Redis cache
+- Inbox Zero web app
+- Cron job for email sync
```bash
-pnpm setup
+inbox-zero start # Start in background
+inbox-zero start --no-detach # Start in foreground
```
-## Usage
+### `inbox-zero stop`
+
+Stops all running containers.
```bash
-# Clone the inbox-zero repository
-git clone https://github.com/elie222/inbox-zero.git
-cd inbox-zero
+inbox-zero stop
+```
-# Run the setup wizard
-inbox-zero setup
+### `inbox-zero logs`
+
+View container logs.
-# Or just run (defaults to setup)
-inbox-zero
+```bash
+inbox-zero logs # Show last 100 lines
+inbox-zero logs -f # Follow logs
+inbox-zero logs -n 500 # Show last 500 lines
```
-The CLI will:
-1. Guide you through configuring OAuth providers (Google/Microsoft)
-2. Set up database connection (Docker or custom PostgreSQL)
-3. Configure Redis (Docker or Upstash)
-4. Select your LLM provider
-5. Generate all required secrets
-6. Create the `.env` file in `apps/web/`
+### `inbox-zero status`
+
+Show status of running containers.
+
+### `inbox-zero update`
-## Development
+Pull the latest Inbox Zero image and optionally restart.
```bash
-cd packages/cli
-pnpm install
-pnpm dev
+inbox-zero update
```
+## Requirements
+
+- [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running
+- OAuth credentials from Google and/or Microsoft
+- An LLM API key (Anthropic, OpenAI, Google, etc.)
+
+## Configuration
+
+All configuration is stored in `~/.inbox-zero/`:
+- `.env` - Environment variables
+- `docker-compose.yml` - Docker Compose configuration
+
+To reconfigure, run `inbox-zero setup` again.
+
## License
See [LICENSE](../../LICENSE) in the repository root.
diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts
index 6b5f1d60dc..748f82be1c 100644
--- a/packages/cli/src/main.ts
+++ b/packages/cli/src/main.ts
@@ -1,13 +1,15 @@
#!/usr/bin/env bun
import { randomBytes } from "node:crypto";
-import { existsSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
import { resolve } from "node:path";
+import { spawn, spawnSync } from "node:child_process";
import { program } from "commander";
import * as p from "@clack/prompts";
-// Detect if we're in an inbox-zero project
-function findProjectRoot(): string {
+// Detect if we're running from within the repo
+function findRepoRoot(): string | null {
const cwd = process.cwd();
// Check if we're in project root (has apps/web directory)
@@ -20,58 +22,136 @@ function findProjectRoot(): string {
return resolve(cwd, "../..");
}
- // Default to cwd and let the user know
- return cwd;
+ return null;
}
-const PROJECT_ROOT = findProjectRoot();
-const ENV_FILE = resolve(PROJECT_ROOT, "apps/web/.env");
+const REPO_ROOT = findRepoRoot();
+const IS_REPO_MODE = REPO_ROOT !== null;
+
+// Config paths depend on mode
+const CONFIG_DIR = IS_REPO_MODE ? REPO_ROOT : resolve(homedir(), ".inbox-zero");
+const ENV_FILE = IS_REPO_MODE
+ ? resolve(REPO_ROOT, "apps/web/.env")
+ : resolve(CONFIG_DIR, ".env");
+const COMPOSE_FILE = IS_REPO_MODE
+ ? resolve(REPO_ROOT, "docker-compose.yml")
+ : resolve(CONFIG_DIR, "docker-compose.yml");
+
+// Ensure config directory exists (only needed for standalone mode)
+function ensureConfigDir() {
+ if (!IS_REPO_MODE && !existsSync(CONFIG_DIR)) {
+ mkdirSync(CONFIG_DIR, { recursive: true });
+ }
+}
// Secret generation
function generateSecret(bytes: number): string {
return randomBytes(bytes).toString("hex");
}
+// Check if Docker is available
+function checkDocker(): boolean {
+ const result = spawnSync("docker", ["--version"], { stdio: "pipe" });
+ return result.status === 0;
+}
+
+// Check if Docker Compose is available (plugin or standalone)
+function checkDockerCompose(): boolean {
+ // First try the Docker CLI plugin (docker compose)
+ const pluginResult = spawnSync("docker", ["compose", "version"], {
+ stdio: "pipe",
+ });
+ if (pluginResult.status === 0) return true;
+
+ // Fallback to standalone docker-compose binary
+ const standaloneResult = spawnSync("docker-compose", ["version"], {
+ stdio: "pipe",
+ });
+ return standaloneResult.status === 0;
+}
+
// Environment variable builder
type EnvConfig = Record;
async function main() {
program
.name("inbox-zero")
- .description("CLI tool for setting up Inbox Zero")
- .version("2.21.15");
+ .description("CLI tool for running Inbox Zero - AI email assistant")
+ .version("2.21.16");
program
.command("setup")
- .description("Interactive setup for Inbox Zero environment")
+ .description("Interactive setup for Inbox Zero")
.action(runSetup);
- // Default to setup if no command provided
+ program
+ .command("start")
+ .description("Start Inbox Zero containers")
+ .option("--no-detach", "Run in foreground (default: runs in background)")
+ .action(runStart);
+
+ program
+ .command("stop")
+ .description("Stop Inbox Zero containers")
+ .action(runStop);
+
+ program
+ .command("logs")
+ .description("View container logs")
+ .option("-f, --follow", "Follow log output", false)
+ .option("-n, --tail ", "Number of lines to show", "100")
+ .action(runLogs);
+
+ program
+ .command("status")
+ .description("Show status of Inbox Zero containers")
+ .action(runStatus);
+
+ program
+ .command("update")
+ .description("Pull latest Inbox Zero image")
+ .action(runUpdate);
+
+ // Default to help if no command
if (process.argv.length === 2) {
- process.argv.push("setup");
+ program.help();
}
await program.parseAsync();
}
+// ═══════════════════════════════════════════════════════════════════════════
+// Setup Command
+// ═══════════════════════════════════════════════════════════════════════════
+
async function runSetup() {
- p.intro("🚀 Inbox Zero Environment Setup");
-
- // Verify we're in an inbox-zero project
- if (!existsSync(resolve(PROJECT_ROOT, "apps/web"))) {
- p.log.error(
- "Could not find inbox-zero project.\n" +
- "Please run this command from the root of a cloned inbox-zero repository:\n\n" +
- " git clone https://github.com/elie222/inbox-zero.git\n" +
- " cd inbox-zero\n" +
- " inbox-zero setup",
- );
- process.exit(1);
+ if (IS_REPO_MODE) {
+ p.intro("🚀 Inbox Zero Environment Setup (Repository Mode)");
+ p.log.info(`Detected repository at: ${REPO_ROOT}`);
+ } else {
+ p.intro("🚀 Inbox Zero Setup (Standalone Mode)");
+
+ // Check Docker only in standalone mode
+ if (!checkDocker()) {
+ p.log.error(
+ "Docker is not installed or not running.\n" +
+ "Please install Docker Desktop: https://www.docker.com/products/docker-desktop/",
+ );
+ process.exit(1);
+ }
+
+ if (!checkDockerCompose()) {
+ p.log.error(
+ "Docker Compose is not available.\n" +
+ "Please update Docker Desktop or install Docker Compose.",
+ );
+ process.exit(1);
+ }
}
- p.log.info(`Project root: ${PROJECT_ROOT}`);
+ ensureConfigDir();
- // Check if .env already exists
+ // Check if already configured
if (existsSync(ENV_FILE)) {
const overwrite = await p.confirm({
message: ".env file already exists. Overwrite it?",
@@ -79,42 +159,78 @@ async function runSetup() {
});
if (p.isCancel(overwrite) || !overwrite) {
- p.cancel("Setup cancelled. Existing .env file preserved.");
+ p.cancel("Setup cancelled. Existing configuration preserved.");
process.exit(0);
}
}
const env: EnvConfig = {};
+ // Default ports
+ let webPort = "3000";
+ let postgresPort = "5432";
+ let redisPort = "8079";
+
// ═══════════════════════════════════════════════════════════════════════════
- // Environment
+ // Ports Configuration (standalone mode only)
// ═══════════════════════════════════════════════════════════════════════════
- const nodeEnv = await p.select({
- message: "Environment",
- options: [
- { value: "development", label: "Development", hint: "local development" },
+ if (!IS_REPO_MODE) {
+ p.note(
+ "Configure ports for Inbox Zero services.\nChange these if you have conflicts with existing services.",
+ "Port Configuration",
+ );
+
+ const ports = await p.group(
{
- value: "production",
- label: "Production",
- hint: "production deployment",
+ web: () =>
+ p.text({
+ message: "Web app port",
+ placeholder: "3000",
+ initialValue: "3000",
+ validate: (v) =>
+ /^\d+$/.test(v) ? undefined : "Must be a valid port number",
+ }),
+ postgres: () =>
+ p.text({
+ message: "PostgreSQL port",
+ placeholder: "5432",
+ initialValue: "5432",
+ validate: (v) =>
+ /^\d+$/.test(v) ? undefined : "Must be a valid port number",
+ }),
+ redis: () =>
+ p.text({
+ message: "Redis HTTP port",
+ placeholder: "8079",
+ initialValue: "8079",
+ validate: (v) =>
+ /^\d+$/.test(v) ? undefined : "Must be a valid port number",
+ }),
},
- ],
- });
+ {
+ onCancel: () => {
+ p.cancel("Setup cancelled.");
+ process.exit(0);
+ },
+ },
+ );
- if (p.isCancel(nodeEnv)) {
- p.cancel("Setup cancelled.");
- process.exit(0);
- }
+ webPort = ports.web;
+ postgresPort = ports.postgres;
+ redisPort = ports.redis;
- env.NODE_ENV = nodeEnv;
+ env.WEB_PORT = webPort;
+ env.POSTGRES_PORT = postgresPort;
+ env.REDIS_HTTP_PORT = redisPort;
+ }
// ═══════════════════════════════════════════════════════════════════════════
// OAuth Providers
// ═══════════════════════════════════════════════════════════════════════════
p.note(
- "Choose which email providers to support.\nPress Enter to skip any field and add it to .env later.",
+ "Choose which email providers to support.\nPress Enter to skip any field and add it later.",
"OAuth Configuration",
);
@@ -141,11 +257,11 @@ async function runSetup() {
`1. Go to Google Cloud Console: https://console.cloud.google.com/apis/credentials
2. Create OAuth 2.0 Client ID (Web application)
3. Add redirect URIs:
- - http://localhost:3000/api/auth/callback/google
- - http://localhost:3000/api/google/linking/callback
+ - http://localhost:${webPort}/api/auth/callback/google
+ - http://localhost:${webPort}/api/google/linking/callback
4. Copy Client ID and Client Secret
-Full guide: https://github.com/elie222/inbox-zero#google-oauth-setup`,
+Full guide: https://docs.getinboxzero.com/self-hosting/google-oauth`,
"Google OAuth Setup",
);
@@ -173,35 +289,7 @@ Full guide: https://github.com/elie222/inbox-zero#google-oauth-setup`,
env.GOOGLE_CLIENT_ID = googleOAuth.clientId || "your-google-client-id";
env.GOOGLE_CLIENT_SECRET =
googleOAuth.clientSecret || "your-google-client-secret";
-
- // Google PubSub (for real-time email notifications)
- p.note(
- `PubSub enables real-time email notifications from Gmail.
-
-1. Create a topic: https://console.cloud.google.com/cloudpubsub/topic/list
-2. Create a Push subscription with URL:
- https://yourdomain.com/api/google/webhook?token=YOUR_TOKEN
-3. Grant publish rights to: gmail-api-push@system.gserviceaccount.com
-
-Full guide: https://developers.google.com/gmail/api/guides/push`,
- "Google PubSub Configuration",
- );
-
- const pubsubTopic = await p.text({
- message: "Google PubSub Topic Name (press Enter to skip)",
- placeholder: "projects/my-project/topics/gmail",
- });
-
- if (p.isCancel(pubsubTopic)) {
- p.cancel("Setup cancelled.");
- process.exit(0);
- }
-
- env.GOOGLE_PUBSUB_TOPIC_NAME =
- pubsubTopic || "projects/your-project/topics/gmail";
- env.GOOGLE_PUBSUB_VERIFICATION_TOKEN = generateSecret(32);
} else {
- // Microsoft only - add placeholder for required Google vars
env.GOOGLE_CLIENT_ID = "skipped";
env.GOOGLE_CLIENT_SECRET = "skipped";
}
@@ -213,12 +301,12 @@ Full guide: https://developers.google.com/gmail/api/guides/push`,
2. Navigate to App registrations → New registration
3. Set account type: "Accounts in any organizational directory and personal Microsoft accounts"
4. Add redirect URIs:
- - http://localhost:3000/api/auth/callback/microsoft
- - http://localhost:3000/api/outlook/linking/callback
+ - http://localhost:${webPort}/api/auth/callback/microsoft
+ - http://localhost:${webPort}/api/outlook/linking/callback
5. Go to Certificates & secrets → New client secret
6. Copy Application (client) ID and the secret Value
-Full guide: https://github.com/elie222/inbox-zero#microsoft-oauth-setup`,
+Full guide: https://docs.getinboxzero.com/self-hosting/microsoft-oauth`,
"Microsoft OAuth Setup",
);
@@ -234,11 +322,6 @@ Full guide: https://github.com/elie222/inbox-zero#microsoft-oauth-setup`,
message: "Microsoft Client Secret (press Enter to skip)",
placeholder: "your-client-secret",
}),
- tenantId: () =>
- p.text({
- message: "Microsoft Tenant ID (press Enter for 'common')",
- placeholder: "common",
- }),
},
{
onCancel: () => {
@@ -252,132 +335,10 @@ Full guide: https://github.com/elie222/inbox-zero#microsoft-oauth-setup`,
microsoftOAuth.clientId || "your-microsoft-client-id";
env.MICROSOFT_CLIENT_SECRET =
microsoftOAuth.clientSecret || "your-microsoft-client-secret";
- env.MICROSOFT_TENANT_ID = microsoftOAuth.tenantId || "common";
+ env.MICROSOFT_TENANT_ID = "common";
env.MICROSOFT_WEBHOOK_CLIENT_STATE = generateSecret(32);
}
- // ═══════════════════════════════════════════════════════════════════════════
- // Database
- // ═══════════════════════════════════════════════════════════════════════════
-
- p.note(
- "Choose how to connect to PostgreSQL.\nDocker Compose includes a local PostgreSQL instance.",
- "Database Configuration",
- );
-
- const dbChoice = await p.select({
- message: "Database setup",
- options: [
- {
- value: "docker",
- label: "Use Docker Compose (local PostgreSQL)",
- hint: "recommended for local development",
- },
- {
- value: "custom",
- label: "Bring your own PostgreSQL",
- hint: "provide a connection string",
- },
- ],
- });
-
- if (p.isCancel(dbChoice)) {
- p.cancel("Setup cancelled.");
- process.exit(0);
- }
-
- if (dbChoice === "docker") {
- env.DATABASE_URL =
- "postgresql://postgres:password@localhost:5432/inbox_zero";
- } else {
- const dbUrl = await p.text({
- message: "PostgreSQL connection string",
- placeholder: "postgresql://user:password@host:5432/database",
- validate: (v) => {
- if (!v) return "Connection string is required";
- if (!v.startsWith("postgresql://") && !v.startsWith("postgres://")) {
- return "Must be a valid PostgreSQL connection string";
- }
- return undefined;
- },
- });
-
- if (p.isCancel(dbUrl)) {
- p.cancel("Setup cancelled.");
- process.exit(0);
- }
-
- env.DATABASE_URL = dbUrl;
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // Redis
- // ═══════════════════════════════════════════════════════════════════════════
-
- p.note(
- "Redis is used for rate limiting and caching.\nDocker Compose includes a local Redis instance.",
- "Redis Configuration",
- );
-
- const redisChoice = await p.select({
- message: "Redis setup",
- options: [
- {
- value: "docker",
- label: "Use Docker Compose (local Redis)",
- hint: "recommended for local development",
- },
- {
- value: "upstash",
- label: "Use Upstash Redis",
- hint: "serverless Redis",
- },
- ],
- });
-
- if (p.isCancel(redisChoice)) {
- p.cancel("Setup cancelled.");
- process.exit(0);
- }
-
- const redisToken = generateSecret(32);
-
- if (redisChoice === "docker") {
- env.UPSTASH_REDIS_URL = "http://localhost:8079";
- env.UPSTASH_REDIS_TOKEN = redisToken;
- env.SRH_TOKEN = redisToken; // For local Redis HTTP adapter
- } else {
- const upstashConfig = await p.group(
- {
- url: () =>
- p.text({
- message: "Upstash Redis REST URL",
- placeholder: "https://xxxx.upstash.io",
- validate: (v) => (!v ? "Upstash URL is required" : undefined),
- }),
- token: () =>
- p.text({
- message: "Upstash Redis REST Token",
- placeholder: "AXxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
- validate: (v) => (!v ? "Upstash token is required" : undefined),
- }),
- },
- {
- onCancel: () => {
- p.cancel("Setup cancelled.");
- process.exit(0);
- },
- },
- );
-
- env.UPSTASH_REDIS_URL = upstashConfig.url;
- env.UPSTASH_REDIS_TOKEN = upstashConfig.token;
-
- p.log.info(
- "Get Upstash credentials at:\nhttps://console.upstash.com/redis",
- );
- }
-
// ═══════════════════════════════════════════════════════════════════════════
// LLM Provider
// ═══════════════════════════════════════════════════════════════════════════
@@ -399,7 +360,6 @@ Full guide: https://github.com/elie222/inbox-zero#microsoft-oauth-setup`,
hint: "access multiple models",
},
{ value: "groq", label: "Groq", hint: "fast inference" },
- { value: "aigateway", label: "Vercel AI Gateway" },
],
});
@@ -410,7 +370,6 @@ Full guide: https://github.com/elie222/inbox-zero#microsoft-oauth-setup`,
env.DEFAULT_LLM_PROVIDER = llmProvider;
- // Default models for each provider
const defaultModels: Record = {
anthropic: {
default: "claude-sonnet-4-5-20250514",
@@ -426,10 +385,6 @@ Full guide: https://github.com/elie222/inbox-zero#microsoft-oauth-setup`,
default: "llama-3.3-70b-versatile",
economy: "llama-3.1-8b-instant",
},
- aigateway: {
- default: "anthropic/claude-sonnet-4.5",
- economy: "anthropic/claude-haiku-4.5",
- },
};
env.DEFAULT_LLM_MODEL = defaultModels[llmProvider].default;
@@ -442,7 +397,6 @@ Full guide: https://github.com/elie222/inbox-zero#microsoft-oauth-setup`,
google: "https://aistudio.google.com/apikey",
openrouter: "https://openrouter.ai/settings/keys",
groq: "https://console.groq.com/keys",
- aigateway: "https://vercel.com/docs/ai-gateway",
};
const apiKeyEnvVar: Record = {
@@ -451,14 +405,14 @@ Full guide: https://github.com/elie222/inbox-zero#microsoft-oauth-setup`,
google: "GOOGLE_API_KEY",
openrouter: "OPENROUTER_API_KEY",
groq: "GROQ_API_KEY",
- aigateway: "AI_GATEWAY_API_KEY",
};
p.log.info(`Get your API key at:\n${llmLinks[llmProvider]}`);
const apiKey = await p.text({
- message: `${llmProvider.charAt(0).toUpperCase() + llmProvider.slice(1)} API Key (press Enter to skip)`,
+ message: `${llmProvider.charAt(0).toUpperCase() + llmProvider.slice(1)} API Key`,
placeholder: "sk-...",
+ validate: (v) => (!v ? "API key is required for AI features" : undefined),
});
if (p.isCancel(apiKey)) {
@@ -466,80 +420,88 @@ Full guide: https://github.com/elie222/inbox-zero#microsoft-oauth-setup`,
process.exit(0);
}
- if (apiKey) {
- env[apiKeyEnvVar[llmProvider]] = apiKey;
- }
+ env[apiKeyEnvVar[llmProvider]] = apiKey;
// ═══════════════════════════════════════════════════════════════════════════
- // Tinybird (Optional)
+ // Auto-generated values
// ═══════════════════════════════════════════════════════════════════════════
- p.note(
- "Tinybird provides analytics for email statistics.\nGet credentials at: https://www.tinybird.co/",
- "Tinybird Analytics (Optional)",
- );
-
- const tinybirdToken = await p.text({
- message: "Tinybird Token - optional, press Enter to skip",
- placeholder: "p.xxxxx",
- });
-
- if (p.isCancel(tinybirdToken)) {
- p.cancel("Setup cancelled.");
- process.exit(0);
- }
-
- if (tinybirdToken) {
- env.TINYBIRD_TOKEN = tinybirdToken;
- env.TINYBIRD_BASE_URL = "https://api.us-east.tinybird.co/";
- env.TINYBIRD_ENCRYPT_SECRET = generateSecret(32);
- env.TINYBIRD_ENCRYPT_SALT = generateSecret(16);
- }
-
- // ═══════════════════════════════════════════════════════════════════════════
- // Base URL
- // ═══════════════════════════════════════════════════════════════════════════
+ const spinner = p.spinner();
+ spinner.start("Generating configuration...");
- const baseUrl = await p.text({
- message: "Base URL for the app",
- placeholder: "http://localhost:3000",
- initialValue: "http://localhost:3000",
- });
+ // Redis token
+ const redisToken = generateSecret(32);
- if (p.isCancel(baseUrl)) {
- p.cancel("Setup cancelled.");
- process.exit(0);
+ if (IS_REPO_MODE) {
+ // Repo mode: Use localhost URLs for local development
+ env.DATABASE_URL = `postgresql://postgres:password@localhost:${postgresPort}/inboxzero`;
+ env.UPSTASH_REDIS_URL = `http://localhost:${redisPort}`;
+ env.UPSTASH_REDIS_TOKEN = redisToken;
+ env.SRH_TOKEN = redisToken;
+ env.NODE_ENV = "development";
+ } else {
+ // Standalone mode: Use Docker network hostnames
+ env.POSTGRES_USER = "postgres";
+ env.POSTGRES_PASSWORD = generateSecret(16);
+ env.POSTGRES_DB = "inboxzero";
+ env.DATABASE_URL = `postgresql://${env.POSTGRES_USER}:${env.POSTGRES_PASSWORD}@db:5432/${env.POSTGRES_DB}`;
+ env.UPSTASH_REDIS_URL = "http://serverless-redis-http:80";
+ env.UPSTASH_REDIS_TOKEN = redisToken;
+ env.SRH_TOKEN = redisToken;
+ env.NODE_ENV = "production";
}
- env.NEXT_PUBLIC_BASE_URL = baseUrl;
-
- // ═══════════════════════════════════════════════════════════════════════════
- // Auto-generated Secrets
- // ═══════════════════════════════════════════════════════════════════════════
-
- const spinner = p.spinner();
- spinner.start("Generating secrets...");
-
+ // Secrets (same for both modes)
env.AUTH_SECRET = generateSecret(32);
env.EMAIL_ENCRYPT_SECRET = generateSecret(32);
env.EMAIL_ENCRYPT_SALT = generateSecret(16);
env.INTERNAL_API_KEY = generateSecret(32);
env.API_KEY_SALT = generateSecret(32);
env.CRON_SECRET = generateSecret(32);
-
- // Self-hosting recommended setting
+ env.GOOGLE_PUBSUB_VERIFICATION_TOKEN = generateSecret(32);
+ // Google PubSub topic - required for Gmail push notifications
+ // Self-hosters need to set up their own topic in Google Cloud Console
+ env.GOOGLE_PUBSUB_TOPIC_NAME =
+ "projects/your-project/topics/inbox-zero-emails";
+
+ // App config
+ env.NEXT_PUBLIC_BASE_URL = `http://localhost:${webPort}`;
env.NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS = "true";
- spinner.stop("Secrets generated");
+ spinner.stop("Configuration generated");
// ═══════════════════════════════════════════════════════════════════════════
- // Write .env file
+ // Write files
// ═══════════════════════════════════════════════════════════════════════════
+ // Only fetch docker-compose.yml in standalone mode
+ if (!IS_REPO_MODE) {
+ spinner.start("Fetching docker-compose.yml from repository...");
+
+ let composeContent: string;
+ try {
+ composeContent = await fetchDockerCompose();
+ } catch {
+ spinner.stop("Failed to fetch docker-compose.yml");
+ p.log.error(
+ "Could not fetch docker-compose.yml from GitHub.\n" +
+ "Please check your internet connection and try again.",
+ );
+ process.exit(1);
+ }
+
+ spinner.stop("Configuration fetched");
+ writeFileSync(COMPOSE_FILE, composeContent);
+ }
+
spinner.start("Writing .env file...");
- const envContent = generateEnvFile(env);
- writeFileSync(ENV_FILE, envContent);
+ // Write .env
+ const envContent = Object.entries(env)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => `${k}=${v}`)
+ .join("\n");
+ writeFileSync(ENV_FILE, `${envContent}\n`);
spinner.stop(".env file created");
@@ -550,28 +512,28 @@ Full guide: https://github.com/elie222/inbox-zero#microsoft-oauth-setup`,
const configuredFeatures = [
wantsGoogle ? "✓ Google OAuth" : "✗ Google OAuth (skipped)",
wantsMicrosoft ? "✓ Microsoft OAuth" : "✗ Microsoft OAuth (skipped)",
- `✓ Database (${dbChoice === "docker" ? "Docker" : "custom"})`,
- `✓ Redis (${redisChoice === "docker" ? "Docker" : "Upstash"})`,
`✓ LLM Provider (${llmProvider})`,
- wantsGoogle ? "✓ Google PubSub" : "✗ Google PubSub (not applicable)",
- tinybirdToken ? "✓ Tinybird Analytics" : "✗ Tinybird Analytics (skipped)",
- "✓ Auto-generated secrets",
+ ...(IS_REPO_MODE
+ ? []
+ : [
+ `✓ Web port: ${webPort}`,
+ `✓ PostgreSQL port: ${postgresPort}`,
+ `✓ Redis HTTP port: ${redisPort}`,
+ ]),
].join("\n");
p.note(configuredFeatures, "Configuration Summary");
p.note(`Environment file saved to:\n${ENV_FILE}`, "Output");
- const useDocker = dbChoice === "docker" || redisChoice === "docker";
-
- if (useDocker) {
+ if (IS_REPO_MODE) {
p.note(
- "# Start with Docker Compose (includes database & Redis):\npnpm docker:up\n\n# Then run database migrations:\npnpm prisma:migrate:dev\n\n# Start the development server:\npnpm dev",
+ "# Start Docker services (database & Redis):\ndocker compose --profile local-db --profile local-redis up -d\n\n# Run database migrations:\npnpm prisma:migrate:dev\n\n# Start the dev server:\npnpm dev\n\n# Then open:\nhttp://localhost:3000",
"Next Steps",
);
} else {
p.note(
- "# Run database migrations:\npnpm prisma:migrate:dev\n\n# Start the development server:\npnpm dev",
+ `# Start Inbox Zero:\ninbox-zero start\n\n# Then open:\nhttp://localhost:${webPort}`,
"Next Steps",
);
}
@@ -579,156 +541,255 @@ Full guide: https://github.com/elie222/inbox-zero#microsoft-oauth-setup`,
p.outro("Setup complete! 🎉");
}
-function generateEnvFile(env: EnvConfig): string {
- const lines: string[] = [
- "# Inbox Zero Environment Configuration",
- "# Generated by setup-env CLI",
- `# ${new Date().toISOString()}`,
- "",
- ];
-
- // Helper to add a section
- // comment: true = always show as comment, false/undefined = show with value or empty
- const addSection = (
- title: string,
- vars: { name: string; comment?: boolean }[],
- ) => {
- const hasAnyNonComment = vars.some((v) => !v.comment);
- if (!hasAnyNonComment && vars.every((v) => env[v.name] === undefined))
- return;
-
- lines.push(
- "# ═══════════════════════════════════════════════════════════════",
+// ═══════════════════════════════════════════════════════════════════════════
+// Start Command
+// ═══════════════════════════════════════════════════════════════════════════
+
+async function runStart(options: { detach: boolean }) {
+ if (IS_REPO_MODE) {
+ p.log.info(
+ "You're in the repository. Use these commands instead:\n\n" +
+ " docker compose --profile all up -d # Start all services\n" +
+ " pnpm dev # Start dev server\n",
);
- lines.push(`# ${title}`);
- lines.push(
- "# ═══════════════════════════════════════════════════════════════",
+ process.exit(0);
+ }
+
+ if (!existsSync(COMPOSE_FILE)) {
+ p.log.error("Inbox Zero is not configured.\nRun 'inbox-zero setup' first.");
+ process.exit(1);
+ }
+
+ p.intro("🚀 Starting Inbox Zero");
+
+ const spinner = p.spinner();
+ spinner.start("Pulling latest image...");
+
+ const pullResult = spawnSync(
+ "docker",
+ ["compose", "-f", COMPOSE_FILE, "pull"],
+ { stdio: "pipe" },
+ );
+
+ if (pullResult.status !== 0) {
+ spinner.stop("Failed to pull image");
+ p.log.error(pullResult.stderr?.toString() || "Unknown error");
+ process.exit(1);
+ }
+
+ spinner.stop("Image pulled");
+
+ if (options.detach) {
+ spinner.start("Starting containers...");
+ } else {
+ spinner.stop("Starting containers in foreground...");
+ }
+
+ const args = ["compose", "-f", COMPOSE_FILE, "up"];
+ if (options.detach) {
+ args.push("-d");
+ }
+
+ const upResult = spawnSync("docker", args, {
+ stdio: options.detach ? "pipe" : "inherit",
+ });
+
+ if (options.detach) {
+ if (upResult.status !== 0) {
+ spinner.stop("Failed to start");
+ p.log.error(
+ upResult.error?.message ||
+ upResult.stderr?.toString() ||
+ `Unknown error (status: ${upResult.status})`,
+ );
+ process.exit(1);
+ }
+
+ spinner.stop("Containers started");
+
+ // Get web port from env (with safe reading)
+ let webPort = "3000";
+ if (existsSync(ENV_FILE)) {
+ try {
+ const envContent = readFileSync(ENV_FILE, "utf-8");
+ webPort = envContent.match(/WEB_PORT=(\d+)/)?.[1] || webPort;
+ } catch {
+ // Use default port if env file can't be read
+ }
+ }
+
+ p.note(
+ `Inbox Zero is running at:\nhttp://localhost:${webPort}\n\nView logs: inbox-zero logs\nStop: inbox-zero stop`,
+ "Running",
);
- for (const { name, comment } of vars) {
- const value = env[name];
- if (comment) {
- // Always show as comment
- lines.push(value !== undefined ? `# ${name}=${value}` : `# ${name}=`);
+ p.outro("Inbox Zero started! 🎉");
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Stop Command
+// ═══════════════════════════════════════════════════════════════════════════
+
+async function runStop() {
+ if (IS_REPO_MODE) {
+ p.log.info("You're in the repository. Use: docker compose down");
+ process.exit(0);
+ }
+
+ if (!existsSync(COMPOSE_FILE)) {
+ p.log.error("Inbox Zero is not configured.");
+ process.exit(1);
+ }
+
+ p.intro("Stopping Inbox Zero");
+
+ const spinner = p.spinner();
+ spinner.start("Stopping containers...");
+
+ const result = spawnSync("docker", ["compose", "-f", COMPOSE_FILE, "down"], {
+ stdio: "pipe",
+ });
+
+ if (result.status !== 0) {
+ spinner.stop("Failed to stop");
+ p.log.error(result.stderr?.toString() || "Unknown error");
+ process.exit(1);
+ }
+
+ spinner.stop("Containers stopped");
+ p.outro("Inbox Zero stopped");
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Logs Command
+// ═══════════════════════════════════════════════════════════════════════════
+
+async function runLogs(options: { follow: boolean; tail: string }) {
+ if (IS_REPO_MODE) {
+ p.log.info("You're in the repository. Use: docker compose logs");
+ process.exit(0);
+ }
+
+ if (!existsSync(COMPOSE_FILE)) {
+ p.log.error("Inbox Zero is not configured.");
+ process.exit(1);
+ }
+
+ const args = ["compose", "-f", COMPOSE_FILE, "logs", "--tail", options.tail];
+ if (options.follow) {
+ args.push("-f");
+ }
+
+ const child = spawn("docker", args, { stdio: "inherit" });
+
+ await new Promise((resolve, reject) => {
+ child.on("close", (code) => {
+ if (code === 0 || options.follow) {
+ resolve();
} else {
- // Show with value or empty
- lines.push(`${name}=${value ?? ""}`);
+ reject(new Error(`docker compose logs exited with code ${code}`));
}
- }
+ });
+ child.on("error", reject);
+ });
+}
- lines.push("");
- };
+// ═══════════════════════════════════════════════════════════════════════════
+// Status Command
+// ═══════════════════════════════════════════════════════════════════════════
+
+async function runStatus() {
+ if (IS_REPO_MODE) {
+ p.log.info("You're in the repository. Use: docker compose ps");
+ process.exit(0);
+ }
+
+ if (!existsSync(COMPOSE_FILE)) {
+ p.log.error("Inbox Zero is not configured.\nRun 'inbox-zero setup' first.");
+ process.exit(1);
+ }
- // Core
- addSection("Core", [
- { name: "NODE_ENV" },
- { name: "DATABASE_URL" },
- { name: "NEXT_PUBLIC_BASE_URL" },
- { name: "NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS" },
- ]);
+ spawnSync("docker", ["compose", "-f", COMPOSE_FILE, "ps"], {
+ stdio: "inherit",
+ });
+}
- // Authentication
- addSection("Authentication", [{ name: "AUTH_SECRET" }]);
+// ═══════════════════════════════════════════════════════════════════════════
+// Update Command
+// ═══════════════════════════════════════════════════════════════════════════
- // Encryption
- addSection("Encryption", [
- { name: "EMAIL_ENCRYPT_SECRET" },
- { name: "EMAIL_ENCRYPT_SALT" },
- ]);
+async function runUpdate() {
+ if (IS_REPO_MODE) {
+ p.log.info("You're in the repository. Use: git pull");
+ process.exit(0);
+ }
- // Google OAuth
- addSection("Google OAuth", [
- { name: "GOOGLE_CLIENT_ID" },
- { name: "GOOGLE_CLIENT_SECRET" },
- ]);
+ if (!existsSync(COMPOSE_FILE)) {
+ p.log.error("Inbox Zero is not configured.");
+ process.exit(1);
+ }
- // Microsoft OAuth
- addSection("Microsoft OAuth", [
- { name: "MICROSOFT_CLIENT_ID" },
- { name: "MICROSOFT_CLIENT_SECRET" },
- { name: "MICROSOFT_TENANT_ID" },
- { name: "MICROSOFT_WEBHOOK_CLIENT_STATE" },
- ]);
-
- // Google PubSub
- addSection("Google PubSub", [
- { name: "GOOGLE_PUBSUB_TOPIC_NAME" },
- { name: "GOOGLE_PUBSUB_VERIFICATION_TOKEN" },
- ]);
-
- // Redis
- addSection("Redis", [
- { name: "UPSTASH_REDIS_URL" },
- { name: "UPSTASH_REDIS_TOKEN" },
- { name: "SRH_TOKEN" },
- { name: "REDIS_URL", comment: true },
- { name: "QSTASH_TOKEN", comment: true },
- { name: "QSTASH_CURRENT_SIGNING_KEY", comment: true },
- { name: "QSTASH_NEXT_SIGNING_KEY", comment: true },
- ]);
+ p.intro("Updating Inbox Zero");
- // LLM Provider
- addSection("LLM Provider", [
- { name: "DEFAULT_LLM_PROVIDER" },
- { name: "DEFAULT_LLM_MODEL" },
- { name: "ECONOMY_LLM_PROVIDER" },
- { name: "ECONOMY_LLM_MODEL" },
- { name: "ANTHROPIC_API_KEY" },
- { name: "OPENAI_API_KEY" },
- { name: "GOOGLE_API_KEY" },
- { name: "OPENROUTER_API_KEY" },
- { name: "GROQ_API_KEY" },
- { name: "AI_GATEWAY_API_KEY" },
- ]);
-
- // Tinybird Analytics
- addSection("Tinybird Analytics (Optional)", [
- { name: "TINYBIRD_TOKEN" },
- { name: "TINYBIRD_BASE_URL" },
- { name: "TINYBIRD_ENCRYPT_SECRET" },
- { name: "TINYBIRD_ENCRYPT_SALT" },
- ]);
-
- // Internal
- addSection("Internal", [
- { name: "INTERNAL_API_KEY" },
- { name: "API_KEY_SALT" },
- { name: "CRON_SECRET" },
- ]);
-
- // Optional Services (always show as comments)
- lines.push(
- "# ═══════════════════════════════════════════════════════════════",
- );
- lines.push("# Optional Services");
- lines.push(
- "# ═══════════════════════════════════════════════════════════════",
+ const spinner = p.spinner();
+ spinner.start("Pulling latest image...");
+
+ const pullResult = spawnSync(
+ "docker",
+ ["compose", "-f", COMPOSE_FILE, "pull"],
+ { stdio: "pipe" },
);
- lines.push("");
- lines.push("# Sentry (error tracking)");
- lines.push("# SENTRY_AUTH_TOKEN=");
- lines.push("# SENTRY_ORGANIZATION=");
- lines.push("# SENTRY_PROJECT=");
- lines.push("# NEXT_PUBLIC_SENTRY_DSN=");
- lines.push("");
- lines.push("# Axiom (logging)");
- lines.push("# NEXT_PUBLIC_AXIOM_DATASET=");
- lines.push("# NEXT_PUBLIC_AXIOM_TOKEN=");
- lines.push("");
- lines.push("# Resend (transactional emails)");
- lines.push("# RESEND_API_KEY=");
- lines.push("");
- lines.push("# PostHog (analytics)");
- lines.push("# NEXT_PUBLIC_POSTHOG_KEY=");
- lines.push("# POSTHOG_API_SECRET=");
- lines.push("# POSTHOG_PROJECT_ID=");
- lines.push("");
- lines.push("# Debug");
- lines.push("# LOG_ZOD_ERRORS=true");
- lines.push("# ENABLE_DEBUG_LOGS=false");
- lines.push("");
-
- return lines.join("\n");
+
+ if (pullResult.status !== 0) {
+ spinner.stop("Failed to pull");
+ p.log.error(pullResult.stderr?.toString() || "Unknown error");
+ process.exit(1);
+ }
+
+ spinner.stop("Image updated");
+
+ const restart = await p.confirm({
+ message: "Restart with new image?",
+ initialValue: true,
+ });
+
+ if (p.isCancel(restart)) {
+ p.outro("Update complete. Run 'inbox-zero start' to use the new version.");
+ return;
+ }
+
+ if (restart) {
+ spinner.start("Restarting...");
+
+ spawnSync("docker", ["compose", "-f", COMPOSE_FILE, "down"], {
+ stdio: "pipe",
+ });
+ spawnSync("docker", ["compose", "-f", COMPOSE_FILE, "up", "-d"], {
+ stdio: "pipe",
+ });
+
+ spinner.stop("Restarted");
+ }
+
+ p.outro("Update complete! 🎉");
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// Docker Compose Fetcher
+// ═══════════════════════════════════════════════════════════════════════════
+
+const COMPOSE_URL =
+ "https://raw.githubusercontent.com/elie222/inbox-zero/main/docker-compose.yml";
+
+async function fetchDockerCompose(): Promise {
+ const response = await fetch(COMPOSE_URL);
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch docker-compose.yml: ${response.statusText}`,
+ );
+ }
+ return response.text();
}
main().catch((error) => {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index facc3fa87d..d582611564 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -672,6 +672,22 @@ importers:
specifier: 4.10.3
version: 4.10.3(@emotion/is-prop-valid@1.2.2)(@portabletext/sanity-bridge@1.1.14(@sanity/schema@4.10.3(@types/react@19.0.10)(debug@4.4.3))(@sanity/types@4.10.3(@types/react@19.0.10)))(@types/node@24.9.1)(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(immer@10.1.3)(jiti@2.6.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(styled-components@6.1.19(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)
+ packages/cli:
+ dependencies:
+ '@clack/prompts':
+ specifier: 0.11.0
+ version: 0.11.0
+ commander:
+ specifier: 14.0.2
+ version: 14.0.2
+ devDependencies:
+ '@types/node':
+ specifier: 22.15.18
+ version: 22.15.18
+ typescript:
+ specifier: 5.7.3
+ version: 5.7.3
+
packages/loops:
dependencies:
loops:
@@ -5417,8 +5433,8 @@ packages:
'@types/mysql@2.15.27':
resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==}
- '@types/node@22.18.12':
- resolution: {integrity: sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==}
+ '@types/node@22.15.18':
+ resolution: {integrity: sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==}
'@types/node@24.9.1':
resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==}
@@ -11482,6 +11498,11 @@ packages:
typeid-js@0.3.0:
resolution: {integrity: sha512-A1EmvIWG6xwYRfHuYUjPltHqteZ1EiDG+HOmbIYXeHUVztmnGrPIfU9KIK1QC30x59ko0r4JsMlwzsALCyiB3Q==}
+ typescript@5.7.3:
+ resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -17887,7 +17908,7 @@ snapshots:
dependencies:
'@types/node': 24.9.1
- '@types/node@22.18.12':
+ '@types/node@22.15.18':
dependencies:
undici-types: 6.21.0
@@ -18131,7 +18152,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
- vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.1)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.1)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
'@vitest/utils@3.2.4':
dependencies:
@@ -24786,7 +24807,7 @@ snapshots:
svix@1.76.1:
dependencies:
'@stablelib/base64': 1.0.1
- '@types/node': 22.18.12
+ '@types/node': 22.15.18
es6-promise: 4.2.8
fast-sha256: 1.3.0
url-parse: 1.5.10
@@ -25179,6 +25200,8 @@ snapshots:
dependencies:
uuidv7: 0.4.4
+ typescript@5.7.3: {}
+
typescript@5.9.3: {}
typo-js@1.3.1: {}
diff --git a/version.txt b/version.txt
index bf3a31063f..5a0ad3b5f6 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-v2.21.15
+v2.21.17