diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1de8f29034..c8e49e2e1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,10 +145,25 @@ jobs: path: dist merge-multiple: true + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build web UI + run: bun --filter @archon/web run build + + - name: Package web dist + run: | + tar czf dist/archon-web.tar.gz -C packages/web/dist . + - name: Generate checksums run: | cd dist - sha256sum archon-* > checksums.txt + sha256sum archon-* archon-web.tar.gz > checksums.txt cat checksums.txt - name: Get version @@ -170,6 +185,7 @@ jobs: generate_release_notes: true files: | dist/archon-* + dist/archon-web.tar.gz dist/checksums.txt body: | ## Installation diff --git a/CLAUDE.md b/CLAUDE.md index 012498439f..caa254b740 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,6 +244,11 @@ bun run cli validate commands my-command # Single command bun run cli complete bun run cli complete --force # Skip uncommitted-changes check +# Start the web UI server (compiled binary only, downloads web UI on first run) +bun run cli serve +bun run cli serve --port 4000 +bun run cli serve --download-only # Download without starting + # Show version bun run cli version ``` @@ -394,11 +399,11 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api'; ### Architecture Layers **Package Split:** -- **@archon/paths**: Path resolution utilities and Pino logger factory (no @archon/* deps) +- **@archon/paths**: Path resolution utilities, Pino logger factory, web dist cache path (`getWebDistDir`) (no @archon/* deps) - **@archon/git**: Git operations - worktrees, branches, repos, exec wrappers (depends only on @archon/paths) - **@archon/isolation**: Worktree isolation types, providers, resolver, error classifiers (depends only on @archon/git + @archon/paths) - **@archon/workflows**: Workflow engine - loader, router, executor, DAG, logger, bundled defaults (depends only on @archon/git + @archon/paths + @hono/zod-openapi + zod; DB/AI/config injected via `WorkflowDeps`) -- **@archon/cli**: Command-line interface for running workflows +- **@archon/cli**: Command-line interface for running workflows and starting the web UI server (depends on @archon/server + @archon/adapters for the serve command) - **@archon/core**: Business logic, database, orchestration, AI clients (provides `createWorkflowStore()` adapter bridging core DB → `IWorkflowStore`) - **@archon/adapters**: Platform adapters for Slack, Telegram, GitHub, Discord (depends on @archon/core) - **@archon/server**: OpenAPIHono HTTP server (Zod + OpenAPI spec generation via `@hono/zod-openapi`), Web adapter (SSE), API routes, Web UI static serving (depends on @archon/adapters) @@ -530,6 +535,7 @@ curl http://localhost:3637/api/conversations//messages │ │ ├── runs/{id}/ # Per-run artifacts ($ARTIFACTS_DIR) │ │ └── uploads/{convId}/ # Web UI file uploads (ephemeral) │ └── logs/ # Workflow execution logs +├── web-dist// # Cached web UI dist (archon serve, binary only) ├── archon.db # SQLite database (when DATABASE_URL not set) └── config.yaml # Global configuration (non-secrets) ``` diff --git a/README.md b/README.md index cd13758c71..6c4c827783 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ The coding agent handles workflow selection, branch naming, and worktree isolati ## Web UI -Archon includes a web dashboard for chatting with your coding agent, running workflows, and monitoring activity. To start it, ask your coding agent to run the frontend from the Archon repo, or run `bun run dev` from the repo root yourself. +Archon includes a web dashboard for chatting with your coding agent, running workflows, and monitoring activity. Binary installs: run `archon serve` to download and start the web UI in one step. From source: ask your coding agent to run the frontend from the Archon repo, or run `bun run dev` from the repo root yourself. Register a project by clicking **+** next to "Project" in the chat sidebar - enter a GitHub URL or local path. Then start a conversation, invoke workflows, and watch progress in real time. diff --git a/bun.lock b/bun.lock index d7cbe31a1b..8c00855c14 100644 --- a/bun.lock +++ b/bun.lock @@ -46,10 +46,12 @@ "archon": "./src/cli.ts", }, "dependencies": { + "@archon/adapters": "workspace:*", "@archon/core": "workspace:*", "@archon/git": "workspace:*", "@archon/isolation": "workspace:*", "@archon/paths": "workspace:*", + "@archon/server": "workspace:*", "@archon/workflows": "workspace:*", "@clack/prompts": "^1.0.0", "dotenv": "^17.2.3", diff --git a/packages/cli/package.json b/packages/cli/package.json index 517abb2680..b5574f4ce2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,14 +8,16 @@ }, "scripts": { "cli": "bun src/cli.ts", - "test": "bun test src/commands/version.test.ts src/commands/setup.test.ts && bun test src/commands/workflow.test.ts && bun test src/commands/isolation.test.ts && bun test src/commands/chat.test.ts", + "test": "bun test src/commands/version.test.ts src/commands/setup.test.ts && bun test src/commands/workflow.test.ts && bun test src/commands/isolation.test.ts && bun test src/commands/chat.test.ts && bun test src/commands/serve.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { + "@archon/adapters": "workspace:*", "@archon/core": "workspace:*", "@archon/git": "workspace:*", "@archon/isolation": "workspace:*", "@archon/paths": "workspace:*", + "@archon/server": "workspace:*", "@archon/workflows": "workspace:*", "@clack/prompts": "^1.0.0", "dotenv": "^17.2.3" diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8852dbc657..8d2fc02af1 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -78,6 +78,7 @@ import { continueCommand } from './commands/continue'; import { chatCommand } from './commands/chat'; import { setupCommand } from './commands/setup'; import { validateWorkflowsCommand, validateCommandsCommand } from './commands/validate'; +import { serveCommand } from './commands/serve'; import { closeDatabase } from '@archon/core'; import { setLogLevel, createLogger } from '@archon/paths'; import * as git from '@archon/git'; @@ -110,6 +111,7 @@ Commands: isolation cleanup --merged Remove environments with branches merged into main continue [msg] Continue work on an existing worktree with prior context complete [...] Complete branch lifecycle (remove worktree + branches) + serve Start the web UI server (downloads web UI on first run) validate workflows [name] Validate workflow definitions and their references validate commands [name] Validate command files version Show version info @@ -130,6 +132,8 @@ Options: --allow-env-keys Grant env-key consent during auto-registration (bypasses the env-leak gate for this codebase; logs an audit entry) + --port Override server port for 'serve' (default: 3090) + --download-only Download web UI without starting the server Examples: archon chat "What does the orchestrator do?" @@ -194,6 +198,8 @@ async function main(): Promise { workflow: { type: 'string' }, 'no-context': { type: 'boolean' }, 'allow-env-keys': { type: 'boolean' }, + port: { type: 'string' }, + 'download-only': { type: 'boolean' }, }, allowPositionals: true, strict: false, // Allow unknown flags to pass through @@ -228,7 +234,7 @@ async function main(): Promise { const subcommand = positionals[1]; // Commands that don't require git repo validation - const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue']; + const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue', 'serve']; const requiresGitRepo = !noGitCommands.includes(command ?? ''); try { @@ -534,6 +540,12 @@ async function main(): Promise { break; } + case 'serve': { + const servePort = values.port !== undefined ? Number(values.port) : undefined; + const downloadOnly = Boolean(values['download-only']); + return await serveCommand({ port: servePort, downloadOnly }); + } + default: if (command === undefined) { console.error('Missing command'); diff --git a/packages/cli/src/commands/serve.test.ts b/packages/cli/src/commands/serve.test.ts new file mode 100644 index 0000000000..df1dead454 --- /dev/null +++ b/packages/cli/src/commands/serve.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test'; + +// Mock @archon/paths BEFORE importing the module under test. +// This sets BUNDLED_IS_BINARY = false (dev mode) so serveCommand rejects. +const mockLogger = { + fatal: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + info: mock(() => undefined), + debug: mock(() => undefined), + trace: mock(() => undefined), +}; +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), + getWebDistDir: mock((version: string) => `/tmp/test-archon/web-dist/${version}`), + BUNDLED_IS_BINARY: false, + BUNDLED_VERSION: 'dev', +})); + +import { serveCommand, parseChecksum } from './serve'; + +describe('parseChecksum', () => { + const validHash = 'a'.repeat(64); + + it('should extract hash for matching filename', () => { + const checksums = [ + `${'b'.repeat(64)} archon-linux-x64`, + `${validHash} archon-web.tar.gz`, + `${'c'.repeat(64)} archon-darwin-arm64`, + ].join('\n'); + + expect(parseChecksum(checksums, 'archon-web.tar.gz')).toBe(validHash); + }); + + it('should handle single-space separator', () => { + const checksums = `${validHash} archon-web.tar.gz\n`; + expect(parseChecksum(checksums, 'archon-web.tar.gz')).toBe(validHash); + }); + + it('should throw for missing filename', () => { + const checksums = `${validHash} archon-linux-x64\n`; + expect(() => parseChecksum(checksums, 'archon-web.tar.gz')).toThrow( + 'Checksum not found for archon-web.tar.gz' + ); + }); + + it('should throw for empty checksums text', () => { + expect(() => parseChecksum('', 'archon-web.tar.gz')).toThrow('Checksum not found'); + }); + + it('should skip blank lines', () => { + const checksums = `\n${validHash} archon-web.tar.gz\n\n`; + expect(parseChecksum(checksums, 'archon-web.tar.gz')).toBe(validHash); + }); + + it('should throw for malformed hash (not 64 hex chars)', () => { + const checksums = 'short_hash archon-web.tar.gz\n'; + expect(() => parseChecksum(checksums, 'archon-web.tar.gz')).toThrow( + 'Malformed checksum entry for archon-web.tar.gz' + ); + }); + + it('should throw for uppercase hex hash', () => { + const checksums = `${'A'.repeat(64)} archon-web.tar.gz\n`; + expect(() => parseChecksum(checksums, 'archon-web.tar.gz')).toThrow( + 'Malformed checksum entry for archon-web.tar.gz' + ); + }); +}); + +describe('serveCommand', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should reject in dev mode (non-binary)', async () => { + const exitCode = await serveCommand({}); + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error: `archon serve` is for compiled binaries only.' + ); + }); + + it('should reject with downloadOnly in dev mode', async () => { + const exitCode = await serveCommand({ downloadOnly: true }); + expect(exitCode).toBe(1); + }); + + it('should reject invalid port (NaN)', async () => { + const exitCode = await serveCommand({ port: NaN }); + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('--port must be an integer between 1 and 65535') + ); + }); + + it('should reject port out of range', async () => { + const exitCode = await serveCommand({ port: 99999 }); + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('--port must be an integer between 1 and 65535') + ); + }); + + it('should reject port 0', async () => { + const exitCode = await serveCommand({ port: 0 }); + expect(exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('--port must be an integer between 1 and 65535') + ); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts new file mode 100644 index 0000000000..2db1d468c1 --- /dev/null +++ b/packages/cli/src/commands/serve.ts @@ -0,0 +1,177 @@ +import { dirname } from 'path'; +import { existsSync, mkdirSync, renameSync, rmSync } from 'fs'; +import { createLogger, getWebDistDir, BUNDLED_IS_BINARY, BUNDLED_VERSION } from '@archon/paths'; + +const log = createLogger('cli.serve'); + +const GITHUB_REPO = 'coleam00/Archon'; + +function toError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +export interface ServeOptions { + /** TCP port to bind. Ignored when downloadOnly is true. Range: 1–65535. */ + port?: number; + /** Download the web UI and exit without starting the server. */ + downloadOnly?: boolean; +} + +export async function serveCommand(opts: ServeOptions): Promise { + if ( + opts.port !== undefined && + (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535) + ) { + console.error(`Error: --port must be an integer between 1 and 65535, got: ${opts.port}`); + return 1; + } + + if (!BUNDLED_IS_BINARY) { + console.error('Error: `archon serve` is for compiled binaries only.'); + console.error('For development, use: bun run dev'); + return 1; + } + + const version = BUNDLED_VERSION; + const webDistDir = getWebDistDir(version); + + if (!existsSync(webDistDir)) { + try { + await downloadWebDist(version, webDistDir); + } catch (err) { + const error = toError(err); + log.error({ err: error, version, webDistDir }, 'web_dist.download_failed'); + console.error(`Error: Failed to download web UI: ${error.message}`); + return 1; + } + } else { + log.info({ webDistDir }, 'web_dist.cache_hit'); + } + + if (opts.downloadOnly) { + log.info({ webDistDir }, 'web_dist.download_completed'); + console.log(`Web UI downloaded to: ${webDistDir}`); + return 0; + } + + // Import server and start (dynamic import keeps CLI startup fast for other commands) + try { + const { startServer } = await import('@archon/server'); + await startServer({ + webDistPath: webDistDir, + port: opts.port, + skipPlatformAdapters: true, + }); + } catch (err) { + const error = toError(err); + log.error({ err: error, version, webDistDir, port: opts.port }, 'server.start_failed'); + console.error(`Error: Server failed to start: ${error.message}`); + return 1; + } + + // Server runs until SIGINT/SIGTERM — never returns + return 0; +} + +async function downloadWebDist(version: string, targetDir: string): Promise { + const tarballUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/archon-web.tar.gz`; + const checksumsUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/checksums.txt`; + + log.info({ version, targetDir }, 'web_dist.download_started'); + console.log(`Web UI not found locally — downloading from release v${version}...`); + + // Download checksums + const checksumsRes = await fetch(checksumsUrl).catch((err: unknown) => { + throw new Error( + `Network error fetching checksums from ${checksumsUrl}: ${(err as Error).message}` + ); + }); + if (!checksumsRes.ok) { + throw new Error( + `Failed to download checksums: ${checksumsRes.status} ${checksumsRes.statusText}` + ); + } + const checksumsText = await checksumsRes.text(); + const expectedHash = parseChecksum(checksumsText, 'archon-web.tar.gz'); + + // Download tarball + console.log(`Downloading ${tarballUrl}...`); + const tarballRes = await fetch(tarballUrl).catch((err: unknown) => { + throw new Error(`Network error fetching tarball from ${tarballUrl}: ${(err as Error).message}`); + }); + if (!tarballRes.ok) { + throw new Error(`Failed to download web UI: ${tarballRes.status} ${tarballRes.statusText}`); + } + const tarballBuffer = await tarballRes.arrayBuffer(); + + // Verify checksum + const hasher = new Bun.CryptoHasher('sha256'); + hasher.update(new Uint8Array(tarballBuffer)); + const actualHash = hasher.digest('hex'); + + if (actualHash !== expectedHash) { + throw new Error(`Checksum mismatch: expected ${expectedHash}, got ${actualHash}`); + } + console.log('Checksum verified.'); + + // Extract to temp dir, then atomic rename + const tmpDir = `${targetDir}.tmp`; + + // Clean up any previous failed attempt + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + + // Extract tarball using tar (available on macOS/Linux) + const proc = Bun.spawn(['tar', 'xzf', '-', '-C', tmpDir, '--strip-components=1'], { + stdin: new Uint8Array(tarballBuffer), + stderr: 'pipe', + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderrText = await new Response(proc.stderr).text(); + cleanupAndThrow(tmpDir, `tar extraction failed (exit ${exitCode}): ${stderrText.trim()}`); + } + + // Verify extraction produced expected layout + if (!existsSync(`${tmpDir}/index.html`)) { + cleanupAndThrow( + tmpDir, + 'Extraction produced unexpected layout — index.html not found in extracted dir' + ); + } + + // Atomic move into place + mkdirSync(dirname(targetDir), { recursive: true }); + try { + renameSync(tmpDir, targetDir); + } catch (err) { + cleanupAndThrow( + tmpDir, + `Failed to move extracted web UI from ${tmpDir} to ${targetDir}: ${(err as Error).message}` + ); + } + console.log(`Extracted to ${targetDir}`); +} + +function cleanupAndThrow(tmpDir: string, message: string): never { + rmSync(tmpDir, { recursive: true, force: true }); + throw new Error(message); +} + +/** + * Parse a SHA-256 checksum from a checksums.txt file (sha256sum format). + * Format: ` ` or ` ` + */ +export function parseChecksum(checksums: string, filename: string): string { + for (const line of checksums.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 2 && parts[1] === filename) { + const hash = parts[0]; + if (!/^[0-9a-f]{64}$/.test(hash)) { + throw new Error(`Malformed checksum entry for ${filename}: "${line.trim()}"`); + } + return hash; + } + } + throw new Error(`Checksum not found for ${filename} in checksums.txt`); +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index e8f5faa3d0..5bfc6ab9f4 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -3,14 +3,31 @@ "compilerOptions": { "noEmit": true, "paths": { + "@archon/adapters": ["../adapters/src"], + "@archon/adapters/*": ["../adapters/src/*"], "@archon/core": ["../core/src"], "@archon/core/*": ["../core/src/*"], + "@archon/server": ["../server/src"], + "@archon/server/*": ["../server/src/*"], "@archon/workflows": ["../workflows/src"], "@archon/workflows/*": ["../workflows/src/*"], "@archon/paths": ["../paths/src"], "@archon/git": ["../git/src"] } }, - "include": ["src/**/*", "../core/src/**/*.ts", "../workflows/src/defaults/text-imports.d.ts"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "../core/src/**/*.test.ts"] + "include": [ + "src/**/*", + "../core/src/**/*.ts", + "../server/src/**/*.ts", + "../adapters/src/**/*.ts", + "../workflows/src/defaults/text-imports.d.ts" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "../core/src/**/*.test.ts", + "../server/src/**/*.test.ts", + "../adapters/src/**/*.test.ts" + ] } diff --git a/packages/docs-web/src/content/docs/reference/archon-directories.md b/packages/docs-web/src/content/docs/reference/archon-directories.md index a3125b1c2a..9b04245041 100644 --- a/packages/docs-web/src/content/docs/reference/archon-directories.md +++ b/packages/docs-web/src/content/docs/reference/archon-directories.md @@ -31,6 +31,7 @@ Archon provides a unified directory and configuration system with: │ ├── source/ # Clone or symlink -> local path │ └── worktrees/ # Git worktrees for this project ├── worktrees/ # Legacy global worktrees (for repos not in workspaces/) +├── web-dist// # Cached web UI dist (archon serve, binary only) └── config.yaml # Global user configuration ``` @@ -86,6 +87,10 @@ getArchonWorktreesPath(): string getArchonConfigPath(): string // Returns: ${ARCHON_HOME}/config.yaml +// Get cached web UI distribution directory for a given version +getWebDistDir(version: string): string +// Returns: ${ARCHON_HOME}/web-dist/${version} + // Get command folder search paths (priority order) getCommandFolderSearchPaths(configuredFolder?: string): string[] // Returns: ['.archon/commands'] + configuredFolder if specified diff --git a/packages/docs-web/src/content/docs/reference/cli.md b/packages/docs-web/src/content/docs/reference/cli.md index 6148fb8bc6..d51244380a 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -50,7 +50,7 @@ archon workflow run plan --cwd /path/to/repo --branch feature-auth "Add OAuth su archon workflow run assist --cwd /path/to/repo --no-worktree "Quick question" ``` -**Note:** Workflow and isolation commands require running from within a git repository. Running from subdirectories automatically resolves to the repo root. The `version`, `help`, `chat`, and `setup` commands work anywhere. +**Note:** Workflow and isolation commands require running from within a git repository. Running from subdirectories automatically resolves to the repo root. The `version`, `help`, `chat`, `setup`, and `serve` commands work anywhere. ## Commands @@ -303,6 +303,32 @@ archon complete feature-auth --force # bypass uncommitted-changes check Use this after a PR is merged and you no longer need the worktree or branches. Accepts multiple branch names in one call. +### `serve` + +Start the web UI server. On first run, downloads a pre-built web UI tarball from the matching GitHub release, verifies the SHA-256 checksum, and extracts it. Subsequent runs use the cached copy. + +**Binary installs only** — in development, use `bun run dev` instead. + +```bash +# Start web UI server (downloads on first run) +archon serve + +# Override the default port +archon serve --port 4000 + +# Download the web UI without starting the server +archon serve --download-only +``` + +**Flags:** + +| Flag | Effect | +|------|--------| +| `--port ` | Override server port (default: 3090, range: 1–65535) | +| `--download-only` | Download and cache the web UI, then exit without starting the server | + +The cached web UI is stored at `~/.archon/web-dist//`. Each version is cached independently, so upgrading the binary automatically downloads the matching web UI. + ### `version` Show version, build type, and database info. diff --git a/packages/paths/src/archon-paths.ts b/packages/paths/src/archon-paths.ts index 45fddc3292..ca8ea73774 100644 --- a/packages/paths/src/archon-paths.ts +++ b/packages/paths/src/archon-paths.ts @@ -198,6 +198,14 @@ export function getDefaultWorkflowsPath(): string { return join(getAppArchonBasePath(), 'workflows', 'defaults'); } +/** + * Returns the path to the cached web UI distribution for a given version. + * Example: ~/.archon/web-dist/v0.3.2/ + */ +export function getWebDistDir(version: string): string { + return join(getArchonHome(), 'web-dist', version); +} + // ============================================================================= // Project-centric path functions // ============================================================================= diff --git a/packages/paths/src/index.ts b/packages/paths/src/index.ts index 3c3fd89618..3f1790b0dd 100644 --- a/packages/paths/src/index.ts +++ b/packages/paths/src/index.ts @@ -25,6 +25,7 @@ export { ensureProjectStructure, createProjectSourceSymlink, findMarkdownFilesRecursive, + getWebDistDir, } from './archon-paths'; // Logger diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4d405b63ba..04633bc8ad 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -126,7 +126,19 @@ export function handleUnhandledRejection(reason: unknown): void { process.exit(1); } -async function main(): Promise { +export interface ServerOptions { + /** + * Override the web dist path (for CLI binary with downloaded web-dist). + * Only effective in production mode (NODE_ENV=production or WEB_UI_DEV unset). + */ + webDistPath?: string; + /** Override the port. Range: 1–65535. */ + port?: number; + /** Run in standalone web-only mode (no Telegram/Slack/GitHub/Discord adapters). */ + skipPlatformAdapters?: boolean; +} + +export async function startServer(opts: ServerOptions = {}): Promise { getLog().info('server_starting'); // Database auto-detected: SQLite (default) or PostgreSQL (if DATABASE_URL set) @@ -278,189 +290,197 @@ async function main(): Promise { await webAdapter.start(); persistence.startPeriodicFlush(); - // Check that at least one platform is configured - const hasTelegram = Boolean(process.env.TELEGRAM_BOT_TOKEN); - const hasDiscord = Boolean(process.env.DISCORD_BOT_TOKEN); - const hasGitHub = Boolean(process.env.GITHUB_TOKEN && process.env.WEBHOOK_SECRET); - const hasGitea = Boolean( - process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET - ); - const hasGitLab = Boolean(process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET); - - if (!hasTelegram && !hasDiscord && !hasGitHub && !hasGitea && !hasGitLab) { - getLog().warn('no_platform_adapters_configured'); - } - - // Initialize GitHub adapter (conditional) + // Platform adapters (skipped in CLI serve mode or when not configured) let github: GitHubAdapter | null = null; - if (process.env.GITHUB_TOKEN && process.env.WEBHOOK_SECRET) { - const botMention = - process.env.GITHUB_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; - github = new GitHubAdapter( - process.env.GITHUB_TOKEN, - process.env.WEBHOOK_SECRET, - lockManager, - botMention - ); - await github.start(); - } else { - getLog().info('github_adapter_skipped'); - } - - // Initialize Gitea adapter (conditional) let gitea: GiteaAdapter | null = null; - if (process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET) { - const giteaBotMention = - process.env.GITEA_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; - gitea = new GiteaAdapter( - process.env.GITEA_URL, - process.env.GITEA_TOKEN, - process.env.GITEA_WEBHOOK_SECRET, - lockManager, - giteaBotMention - ); - await gitea.start(); - } else { - getLog().info('gitea_adapter_skipped'); - } - - // Initialize GitLab adapter (conditional) let gitlab: GitLabAdapter | null = null; - if (process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET) { - const gitlabBotMention = - process.env.GITLAB_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; - gitlab = new GitLabAdapter( - process.env.GITLAB_TOKEN, - process.env.GITLAB_WEBHOOK_SECRET, - lockManager, - process.env.GITLAB_URL || undefined, - gitlabBotMention + let discord: DiscordAdapter | null = null; + let slack: SlackAdapter | null = null; + + if (!opts.skipPlatformAdapters) { + // Check that at least one platform is configured + const hasTelegram = Boolean(process.env.TELEGRAM_BOT_TOKEN); + const hasDiscord = Boolean(process.env.DISCORD_BOT_TOKEN); + const hasGitHub = Boolean(process.env.GITHUB_TOKEN && process.env.WEBHOOK_SECRET); + const hasGitea = Boolean( + process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET ); - await gitlab.start(); - } else { - getLog().info('gitlab_adapter_skipped'); - } + const hasGitLab = Boolean(process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET); - // Initialize Discord adapter (conditional) - let discord: DiscordAdapter | null = null; - if (process.env.DISCORD_BOT_TOKEN) { - const discordStreamingMode = (process.env.DISCORD_STREAMING_MODE ?? 'batch') as - | 'stream' - | 'batch'; - discord = new DiscordAdapter(process.env.DISCORD_BOT_TOKEN, discordStreamingMode); - const discordAdapter = discord; // Capture for use in callback - - // Register message handler - discordAdapter.onMessage(async message => { - // Get initial conversation ID - let conversationId = discordAdapter.getConversationId(message); - - // Skip if no content - if (!message.content) return; - - // Check if bot was mentioned (required for activation) - // Exception: DMs don't require mention - const isDM = !message.guild; - if (!isDM && !discordAdapter.isBotMentioned(message)) { - return; // Ignore messages that don't mention the bot - } + if (!hasTelegram && !hasDiscord && !hasGitHub && !hasGitea && !hasGitLab) { + getLog().warn('no_platform_adapters_configured'); + } - // Strip the bot mention from the message - const content = discordAdapter.stripBotMention(message); - if (!content) return; // Message was only a mention with no content + // Initialize GitHub adapter (conditional) + if (process.env.GITHUB_TOKEN && process.env.WEBHOOK_SECRET) { + const botMention = + process.env.GITHUB_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; + github = new GitHubAdapter( + process.env.GITHUB_TOKEN, + process.env.WEBHOOK_SECRET, + lockManager, + botMention + ); + await github.start(); + } else { + getLog().info('github_adapter_skipped'); + } - // Ensure we're responding in a thread - creates one if needed - conversationId = await discordAdapter.ensureThread(conversationId, message); + // Initialize Gitea adapter (conditional) + if (process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET) { + const giteaBotMention = + process.env.GITEA_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; + gitea = new GiteaAdapter( + process.env.GITEA_URL, + process.env.GITEA_TOKEN, + process.env.GITEA_WEBHOOK_SECRET, + lockManager, + giteaBotMention + ); + await gitea.start(); + } else { + getLog().info('gitea_adapter_skipped'); + } - // Check for thread context (now we're guaranteed to be in a thread if applicable) - let threadContext: string | undefined; - let parentConversationId: string | undefined; + // Initialize GitLab adapter (conditional) + if (process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET) { + const gitlabBotMention = + process.env.GITLAB_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; + gitlab = new GitLabAdapter( + process.env.GITLAB_TOKEN, + process.env.GITLAB_WEBHOOK_SECRET, + lockManager, + process.env.GITLAB_URL || undefined, + gitlabBotMention + ); + await gitlab.start(); + } else { + getLog().info('gitlab_adapter_skipped'); + } - if (discordAdapter.isThread(message)) { - // Fetch thread history for context (exclude current message) - const history = await discordAdapter.fetchThreadHistory(message); - if (history.length > 1) { - threadContext = history.slice(0, -1).join('\n'); + // Initialize Discord adapter (conditional) + if (process.env.DISCORD_BOT_TOKEN) { + const discordStreamingMode = (process.env.DISCORD_STREAMING_MODE ?? 'batch') as + | 'stream' + | 'batch'; + discord = new DiscordAdapter(process.env.DISCORD_BOT_TOKEN, discordStreamingMode); + const discordAdapter = discord; // Capture for use in callback + + // Register message handler + discordAdapter.onMessage(async message => { + // Get initial conversation ID + let conversationId = discordAdapter.getConversationId(message); + + // Skip if no content + if (!message.content) return; + + // Check if bot was mentioned (required for activation) + // Exception: DMs don't require mention + const isDM = !message.guild; + if (!isDM && !discordAdapter.isBotMentioned(message)) { + return; // Ignore messages that don't mention the bot } - // Get parent channel ID for context inheritance - parentConversationId = discordAdapter.getParentChannelId(message) ?? undefined; - } + // Strip the bot mention from the message + const content = discordAdapter.stripBotMention(message); + if (!content) return; // Message was only a mention with no content - // Fire-and-forget: handler returns immediately, processing happens async - lockManager - .acquireLock(conversationId, async () => { - await handleMessage(discordAdapter, conversationId, content, { - threadContext, - parentConversationId, - isolationHints: { workflowType: 'thread', workflowId: conversationId }, - }); - }) - .catch(createMessageErrorHandler('Discord', discordAdapter, conversationId)); - }); + // Ensure we're responding in a thread - creates one if needed + conversationId = await discordAdapter.ensureThread(conversationId, message); - await discord.start(); - } else { - getLog().info('discord_adapter_skipped'); - } + // Check for thread context (now we're guaranteed to be in a thread if applicable) + let threadContext: string | undefined; + let parentConversationId: string | undefined; - // Initialize Slack adapter (conditional) - let slack: SlackAdapter | null = null; - if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) { - const slackStreamingMode = (process.env.SLACK_STREAMING_MODE ?? 'batch') as 'stream' | 'batch'; - slack = new SlackAdapter( - process.env.SLACK_BOT_TOKEN, - process.env.SLACK_APP_TOKEN, - slackStreamingMode - ); - const slackAdapter = slack; // Capture for use in callback + if (discordAdapter.isThread(message)) { + // Fetch thread history for context (exclude current message) + const history = await discordAdapter.fetchThreadHistory(message); + if (history.length > 1) { + threadContext = history.slice(0, -1).join('\n'); + } - // Register message handler - slackAdapter.onMessage(async event => { - const conversationId = slackAdapter.getConversationId(event); + // Get parent channel ID for context inheritance + parentConversationId = discordAdapter.getParentChannelId(message) ?? undefined; + } - // Skip if no text - if (!event.text) return; + // Fire-and-forget: handler returns immediately, processing happens async + lockManager + .acquireLock(conversationId, async () => { + await handleMessage(discordAdapter, conversationId, content, { + threadContext, + parentConversationId, + isolationHints: { workflowType: 'thread', workflowId: conversationId }, + }); + }) + .catch(createMessageErrorHandler('Discord', discordAdapter, conversationId)); + }); - // Strip the bot mention from the message - const content = slackAdapter.stripBotMention(event.text); - if (!content) return; // Message was only a mention with no content + await discord.start(); + } else { + getLog().info('discord_adapter_skipped'); + } - // Check for thread context - let threadContext: string | undefined; - let parentConversationId: string | undefined; + // Initialize Slack adapter (conditional) + if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) { + const slackStreamingMode = (process.env.SLACK_STREAMING_MODE ?? 'batch') as + | 'stream' + | 'batch'; + slack = new SlackAdapter( + process.env.SLACK_BOT_TOKEN, + process.env.SLACK_APP_TOKEN, + slackStreamingMode + ); + const slackAdapter = slack; // Capture for use in callback - if (slackAdapter.isThread(event)) { - // Fetch thread history for context (exclude current message) - const history = await slackAdapter.fetchThreadHistory(event); - if (history.length > 1) { - threadContext = history.slice(0, -1).join('\n'); - } + // Register message handler + slackAdapter.onMessage(async event => { + const conversationId = slackAdapter.getConversationId(event); - // Get parent conversation ID for context inheritance - parentConversationId = slackAdapter.getParentConversationId(event) ?? undefined; - } + // Skip if no text + if (!event.text) return; - // Fire-and-forget: handler returns immediately, processing happens async - lockManager - .acquireLock(conversationId, async () => { - await handleMessage(slackAdapter, conversationId, content, { - threadContext, - parentConversationId, - isolationHints: { workflowType: 'thread', workflowId: conversationId }, - }); - }) - .catch(createMessageErrorHandler('Slack', slackAdapter, conversationId)); - }); + // Strip the bot mention from the message + const content = slackAdapter.stripBotMention(event.text); + if (!content) return; // Message was only a mention with no content - await slack.start(); + // Check for thread context + let threadContext: string | undefined; + let parentConversationId: string | undefined; + + if (slackAdapter.isThread(event)) { + // Fetch thread history for context (exclude current message) + const history = await slackAdapter.fetchThreadHistory(event); + if (history.length > 1) { + threadContext = history.slice(0, -1).join('\n'); + } + + // Get parent conversation ID for context inheritance + parentConversationId = slackAdapter.getParentConversationId(event) ?? undefined; + } + + // Fire-and-forget: handler returns immediately, processing happens async + lockManager + .acquireLock(conversationId, async () => { + await handleMessage(slackAdapter, conversationId, content, { + threadContext, + parentConversationId, + isolationHints: { workflowType: 'thread', workflowId: conversationId }, + }); + }) + .catch(createMessageErrorHandler('Slack', slackAdapter, conversationId)); + }); + + await slack.start(); + } else { + getLog().info('slack_adapter_skipped'); + } } else { - getLog().info('slack_adapter_skipped'); + getLog().info('platform_adapters_skipped'); } // Setup Hono server const app = new OpenAPIHono({ defaultHook: validationErrorHook }); - const port = await getPort(); + const port = opts.port ?? (await getPort()); // Global error handler for unhandled exceptions app.onError((err, c) => { @@ -581,11 +601,9 @@ async function main(): Promise { if (process.env.NODE_ENV === 'production' || !process.env.WEB_UI_DEV) { const { serveStatic } = await import('hono/bun'); const pathModule = await import('path'); - const webDistPath = pathModule.join( - pathModule.dirname(pathModule.dirname(import.meta.dir)), - 'web', - 'dist' - ); + const webDistPath = + opts.webDistPath ?? + pathModule.join(pathModule.dirname(pathModule.dirname(import.meta.dir)), 'web', 'dist'); app.use('/assets/*', serveStatic({ root: webDistPath })); // SPA fallback - serve index.html for unmatched routes (after all API routes) @@ -601,9 +619,9 @@ async function main(): Promise { }); getLog().info({ port: server.port, hostname }, 'server_listening'); - // Initialize Telegram adapter (conditional) + // Initialize Telegram adapter (conditional, skipped in CLI serve mode) let telegram: TelegramAdapter | null = null; - if (process.env.TELEGRAM_BOT_TOKEN) { + if (!opts.skipPlatformAdapters && process.env.TELEGRAM_BOT_TOKEN) { const streamingMode = (process.env.TELEGRAM_STREAMING_MODE ?? 'stream') as 'stream' | 'batch'; telegram = new TelegramAdapter(process.env.TELEGRAM_BOT_TOKEN, streamingMode); const telegramAdapter = telegram; // Capture for use in callback @@ -627,7 +645,7 @@ async function main(): Promise { getLog().error({ err: error, errorType: error.constructor.name }, 'telegram.start_failed'); telegram = null; // Don't include in active platforms or shutdown } - } else { + } else if (!opts.skipPlatformAdapters) { getLog().info('telegram_adapter_skipped'); } @@ -714,8 +732,10 @@ async function checkGhAuth(): Promise { } } -// Run the application -main().catch(error => { - getLog().fatal({ err: error }, 'startup_failed'); - process.exit(1); -}); +// Run the application when executed directly (not imported as a library) +if (import.meta.main) { + startServer().catch(error => { + getLog().fatal({ err: error }, 'startup_failed'); + process.exit(1); + }); +}