From 9987fb790444396fa7454c0a59284f32fc036c03 Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Thu, 9 Apr 2026 20:02:15 +0300 Subject: [PATCH 1/4] Investigate issue #978: one-command web UI install via archon serve --- .claude/PRPs/issues/issue-978.md | 538 +++++++++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 .claude/PRPs/issues/issue-978.md diff --git a/.claude/PRPs/issues/issue-978.md b/.claude/PRPs/issues/issue-978.md new file mode 100644 index 0000000000..1d11f53601 --- /dev/null +++ b/.claude/PRPs/issues/issue-978.md @@ -0,0 +1,538 @@ +# Investigation: One-command web UI install via `archon serve` + +**Issue**: #978 (https://github.com/coleam00/Archon/issues/978) +**Type**: ENHANCEMENT +**Investigated**: 2026-04-09T12:00:00Z + +### Assessment + +| Metric | Value | Reasoning | +| ---------- | ------ | --------------------------------------------------------------------------------------------------------------------------------- | +| Priority | MEDIUM | High user value (removes clone+build friction), but existing Docker path and clone path work; not blocking other work | +| Complexity | HIGH | 8+ files across CLI, server, CI, build scripts; server refactor is the hardest part — `main()` is 600 lines with no library API | +| Confidence | HIGH | Clear codebase analysis, all integration points mapped, no unknowns in the download/extract path; server refactor scope is bounded | + +--- + +## Problem Statement + +The compiled Archon CLI binary includes only `packages/cli/src/cli.ts` — no server, no web UI, no `archon serve` command. Users who want the web UI must clone the entire monorepo, install Bun, run `bun install` (2274 packages), and `bun dev`. There is no one-command path to get a working web UI from the binary install. + +--- + +## Analysis + +### Change Rationale + +The web UI is the most discoverable part of the product, but it's behind the highest friction install path. The proposed approach — lazy-fetching a pre-built web UI tarball from GitHub releases on first `archon serve` — keeps the CLI binary small for CLI-only users while giving web UI users a one-command experience: `brew install coleam00/archon/archon && archon serve`. + +### Key Design Decision: Server as Library vs Embedded Mini-Server + +The current server (`packages/server/src/index.ts`) is a 721-line script with a monolithic `main()` function (line 129-718). It has no `startServer()` export and cannot be imported as a library. Two approaches: + +**Option A: Full server refactor** — Extract `main()` into an exported `startServer(opts)` function, make `@archon/server` a dependency of `@archon/cli`, compile the full server into the binary. Binary grows from ~50MB to ~65MB. All platform adapters (Slack, Telegram, GitHub, Discord) would be compiled in. + +**Option B: Minimal embedded server** — Create a lightweight Hono server in `packages/cli/src/commands/serve.ts` that only registers API routes + static serving. No platform adapters. Binary stays closer to current size. Uses `registerApiRoutes()` (already exported from `packages/server/src/routes/api.ts:837`) as the core building block. + +**Recommendation: Option A (full refactor)** because: +- Option B would duplicate server initialization logic and diverge over time +- Platform adapters are only instantiated when env vars are present (all conditional, see `index.ts:296-459`) — zero cost if not configured +- The binary size increase (~15MB) is acceptable +- Users get the full server experience, not a subset + +### Affected Files + +| File | Lines | Action | Description | +|------|-------|--------|-------------| +| `packages/cli/src/commands/serve.ts` | NEW | CREATE | `archon serve` command: download web-dist, start server | +| `packages/cli/src/cli.ts` | 57-82, 231, 266+ | UPDATE | Add `'serve'` to `noGitCommands`, add `case 'serve'` | +| `packages/cli/package.json` | deps | UPDATE | Add `@archon/server` and `@archon/adapters` as dependencies | +| `packages/server/src/index.ts` | 129-718 | UPDATE | Extract `main()` into exported `startServer(opts)` | +| `packages/server/src/index.ts` | 579-593 | UPDATE | Accept `webDistPath` parameter instead of computing from `import.meta.dir` | +| `.github/workflows/release.yml` | 140-173 | UPDATE | Add web UI build + tarball upload step | +| `scripts/build-binaries.sh` | — | NONE | No change needed — `bun build --compile` follows imports automatically | +| `packages/paths/src/archon-paths.ts` | — | UPDATE | Add `getWebDistPath(version)` helper | +| Tests | NEW | CREATE | Cover download, checksum, extraction, server startup from CLI | + +### Integration Points + +- `packages/cli/src/cli.ts:57-82` imports all commands after dotenv setup +- `packages/server/src/routes/api.ts:837` exports `registerApiRoutes(app, webAdapter, lockManager)` — the only reusable server building block +- `packages/paths/src/bundled-build.ts` provides `BUNDLED_VERSION` for constructing release URLs +- `packages/paths/src/archon-paths.ts:56-74` provides `getArchonHome()` for cache location +- `packages/server/src/index.ts:581-593` resolves `webDistPath` from `import.meta.dir` — needs parameterization +- `.github/workflows/release.yml:163-173` publishes release assets via `softprops/action-gh-release@v2` + +### Git History + +- **Server last touched**: `4b2bcb0e` (env-leak-gate polish) — active development area +- **CLI last touched**: `dddff870` (embed git commit hash in version) — recent changes +- **Build scripts**: `9adc54af` (wire release workflow to build-binaries.sh) — recently stabilized + +--- + +## Implementation Plan + +### Step 1: Extract `startServer(opts)` from server's `main()` + +**File**: `packages/server/src/index.ts` +**Lines**: 129-718 +**Action**: UPDATE + +**Current code (simplified):** +```typescript +async function main(): Promise { + // 600 lines of initialization, adapter creation, route registration, Bun.serve() +} + +main().catch(error => { ... process.exit(1); }); +``` + +**Required change:** + +```typescript +export interface ServerOptions { + /** Override the web dist path (for CLI binary with downloaded web-dist) */ + webDistPath?: string; + /** Override the port */ + port?: number; + /** Skip platform adapter initialization (CLI serve mode) */ + skipPlatformAdapters?: boolean; +} + +export async function startServer(opts: ServerOptions = {}): Promise { + // Move entire main() body here + // Replace webDistPath computation (lines 584-588) with: + // opts.webDistPath ?? pathModule.join(pathModule.dirname(pathModule.dirname(import.meta.dir)), 'web', 'dist') + // Replace port with: opts.port ?? getPort() + // Wrap platform adapter blocks with: if (!opts.skipPlatformAdapters) { ... } +} + +// Keep backward compat: script entry point still works +if (import.meta.main) { + startServer().catch(error => { + getLog().fatal({ error: error instanceof Error ? error.message : String(error) }, 'startup_failed'); + process.exit(1); + }); +} +``` + +**Why**: Makes the server importable as a library. `import.meta.main` guard ensures the file still works as a standalone script for `bun dev`. + +--- + +### Step 2: Add `getWebDistDir()` path helper + +**File**: `packages/paths/src/archon-paths.ts` +**Action**: UPDATE + +**Add function:** +```typescript +/** + * 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); +} +``` + +**Why**: Centralizes the cache location logic, consistent with existing `getArchonHome()` patterns. + +--- + +### Step 3: Create `archon serve` command + +**File**: `packages/cli/src/commands/serve.ts` +**Action**: CREATE + +```typescript +import { existsSync } from 'fs'; +import { createLogger, getWebDistDir } from '@archon/paths'; +import { BUNDLED_IS_BINARY, BUNDLED_VERSION } from '@archon/paths/bundled-build'; + +const log = createLogger('cli.serve'); + +const GITHUB_REPO = 'coleam00/Archon'; + +interface ServeOptions { + port?: number; + downloadOnly?: boolean; +} + +export async function serveCommand(opts: ServeOptions): Promise { + const version = BUNDLED_IS_BINARY ? BUNDLED_VERSION : 'dev'; + + if (version === 'dev') { + console.error('Error: `archon serve` is for compiled binaries only.'); + console.error('For development, use: bun run dev'); + return 1; + } + + const webDistDir = getWebDistDir(version); + + if (!existsSync(webDistDir)) { + await downloadWebDist(version, webDistDir); + } + + if (opts.downloadOnly) { + log.info({ webDistDir }, 'web_dist.download_completed'); + console.log(`Web UI downloaded to: ${webDistDir}`); + return 0; + } + + // Import server and start + const { startServer } = await import('@archon/server'); + await startServer({ + webDistPath: webDistDir, + port: opts.port, + skipPlatformAdapters: false, // Start all configured adapters + }); + + // 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`; + + console.log(`Web UI not found locally — downloading from release v${version}...`); + + // Download checksums + const checksumsRes = await fetch(checksumsUrl); + 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); + 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`; + const { mkdirSync, renameSync, rmSync } = await import('fs'); + + // 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), + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + rmSync(tmpDir, { recursive: true, force: true }); + throw new Error(`tar extraction failed with exit code ${exitCode}`); + } + + // Atomic move + renameSync(tmpDir, targetDir); + console.log(`Extracted to ${targetDir}`); +} + +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) { + return parts[0]; + } + } + throw new Error(`Checksum not found for ${filename} in checksums.txt`); +} +``` + +**Why**: Self-contained command following existing CLI patterns. Atomic extraction prevents half-broken state. Checksum verification prevents supply chain attacks. + +--- + +### Step 4: Wire `serve` into CLI command dispatch + +**File**: `packages/cli/src/cli.ts` +**Lines**: 57-82, 231, 266+ +**Action**: UPDATE + +**Change 1** — Add import (after line 82): +```typescript +import { serveCommand } from './commands/serve.js'; +``` + +**Change 2** — Add to `noGitCommands` (line 231): +```typescript +const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue', 'serve']; +``` + +**Change 3** — Add case in switch (after the existing `case 'continue'` block): +```typescript +case 'serve': { + const servePort = values.port ? Number(values.port) : undefined; + const downloadOnly = Boolean(values['download-only']); + return await serveCommand({ port: servePort, downloadOnly }); +} +``` + +**Change 4** — Add `--port` and `--download-only` to `parseArgs` options: +```typescript +port: { type: 'string' }, +'download-only': { type: 'boolean', default: false }, +``` + +**Change 5** — Update `printUsage()` to include `serve`: +``` + serve Start the web UI server (downloads web UI on first run) + --port Override server port (default: 3090) + --download-only Download web UI without starting the server +``` + +**Why**: Follows exact patterns of existing commands. `serve` doesn't need a git repo. + +--- + +### Step 5: Add `@archon/server` dependency to CLI package + +**File**: `packages/cli/package.json` +**Action**: UPDATE + +Add to `dependencies`: +```json +"@archon/server": "workspace:*", +"@archon/adapters": "workspace:*" +``` + +**Why**: The CLI needs to import `startServer` from `@archon/server`. `@archon/adapters` is a transitive dependency of `@archon/server` and should be explicit. + +--- + +### Step 6: Update release CI to build and publish web UI tarball + +**File**: `.github/workflows/release.yml` +**Action**: UPDATE + +**Add new job** (or add steps to existing `release` job, after artifact download): + +```yaml + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - 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-* archon-web.tar.gz > checksums.txt + cat checksums.txt +``` + +**Update** the `files:` block in the release step: +```yaml + files: | + dist/archon-* + dist/archon-web.tar.gz + dist/checksums.txt +``` + +**Why**: Publishes a single platform-independent web UI tarball alongside the existing per-platform binaries. Checksums cover all artifacts. + +--- + +### Step 7: Add/Update Tests + +**File**: `packages/cli/src/commands/serve.test.ts` +**Action**: CREATE + +**Test cases to add:** + +```typescript +describe('serveCommand', () => { + it('should reject in dev mode (non-binary)', () => { + // Mock BUNDLED_IS_BINARY = false + // Expect exit code 1 with "compiled binaries only" message + }); + + it('should download web-dist when not cached', () => { + // Mock fetch to return tarball + checksums + // Verify extraction to correct path + }); + + it('should skip download when already cached', () => { + // Pre-create the web-dist dir + // Verify no fetch calls + }); + + it('should fail on checksum mismatch', () => { + // Mock fetch with wrong checksum + // Expect error, no leftover .tmp dir + }); + + it('should handle network failure gracefully', () => { + // Mock fetch to throw + // Expect actionable error message + }); + + it('should support --download-only', () => { + // Mock fetch, run with downloadOnly: true + // Verify no startServer call + }); +}); + +describe('parseChecksum', () => { + it('should extract hash for matching filename', () => { + // Known checksums.txt format + }); + + it('should throw for missing filename', () => { + // checksums.txt without the expected entry + }); +}); +``` + +--- + +## Patterns to Follow + +**From codebase — mirror these exactly:** + +```typescript +// SOURCE: packages/cli/src/commands/version.ts:79-88 +// Pattern for binary detection +if (BUNDLED_IS_BINARY) { + version = BUNDLED_VERSION; + gitCommit = BUNDLED_GIT_COMMIT; +} else { + const devInfo = await getDevVersion(); + version = devInfo.version; + gitCommit = await getDevGitCommit(); +} +``` + +```typescript +// SOURCE: packages/paths/src/archon-paths.ts:56-74 +// Pattern for path resolution with ARCHON_HOME override +export function getArchonHome(): string { + if (isDocker()) { + return '/.archon'; + } + const envHome = process.env.ARCHON_HOME; + if (envHome) { /* ... */ return expandTilde(envHome); } + return join(homedir(), '.archon'); +} +``` + +```typescript +// SOURCE: packages/server/src/index.ts:579-593 +// Pattern for static file serving (to be parameterized) +if (process.env.NODE_ENV === 'production' || !process.env.WEB_UI_DEV) { + const { serveStatic } = await import('hono/bun'); + app.use('/assets/*', serveStatic({ root: webDistPath })); + app.get('*', serveStatic({ root: webDistPath, path: 'index.html' })); +} +``` + +--- + +## Edge Cases & Risks + +| Risk/Edge Case | Mitigation | +|---------------|------------| +| Server refactor breaks `bun dev` | `import.meta.main` guard keeps script-mode working; test both paths | +| Binary size bloat from including server | Monitor: current ~50MB, expected ~65MB. Acceptable for the value. | +| Tarball extraction fails (permissions, disk space) | Atomic extraction (`.tmp` → rename); clean up on failure; clear error message | +| GitHub release rate limiting | `fetch` will return 403 — surface the error with retry suggestion | +| Air-gapped environments | `--download-only` allows pre-caching; future `--web-dist ` for offline | +| Version mismatch (binary v0.3.2 but no release exists yet) | Fail with "release not found" — only happens if someone builds from source with wrong version | +| `tar` not available on system | Available on all macOS/Linux; for Windows, use Bun's built-in tar or `decompress` | +| Concurrent `archon serve` calls during first download | Atomic rename prevents corruption; second process sees complete dir or retries | +| `@archon/server` import increases CLI startup time | Use dynamic `await import()` in serve command only — other commands unaffected | + +--- + +## Validation + +### Automated Checks + +```bash +bun run type-check +bun run test +bun run lint +bun run validate # Full pre-PR validation +``` + +### Manual Verification + +1. Run `bun run dev` — verify server still starts normally (script mode preserved) +2. Build binary: `VERSION=test scripts/build-binaries.sh` — verify it compiles +3. Run binary with `archon serve` — verify download + extraction + server start +4. Run binary with `archon serve --download-only` — verify download without server +5. Run binary with `archon serve` a second time — verify cached (no download) +6. Run `archon workflow list` — verify no startup time regression from server dep +7. Verify `archon serve --port 4000` — verify port override works + +--- + +## Scope Boundaries + +**IN SCOPE:** +- Server library refactor (extract `startServer()`) +- `archon serve` CLI command with download + checksum + extract +- `--port` and `--download-only` flags +- Release CI changes to build and publish `archon-web.tar.gz` +- Path helper for web-dist cache location +- Tests for download/extract/checksum logic + +**OUT OF SCOPE (do not touch):** +- `bun dev` workflow — stays as-is for contributors +- Docker image — orthogonal, not affected +- CDN mirroring — GitHub releases sufficient for now +- `archon serve --web-version=latest` — defer to future issue +- `archon serve --offline --web-dist=./path` — defer (can add later) +- Homebrew formula changes — just update docs, no formula change needed +- Auto-update of cached web-dist — version-keyed dirs handle this naturally +- Deprecating clone-and-bun-dev — keep for contributors +- Platform adapter lazy loading optimization — all adapters already conditional on env vars + +--- + +## Implementation Order + +The steps have a strict dependency chain: + +1. **Step 2** (path helper) — no deps, can go first +2. **Step 1** (server refactor) — the hardest part, do early +3. **Step 5** (CLI package.json dep) — needed before Step 3 +4. **Step 3** (serve command) — depends on Steps 1, 2, 5 +5. **Step 4** (CLI wiring) — depends on Step 3 +6. **Step 7** (tests) — depends on Steps 3, 4 +7. **Step 6** (CI changes) — independent, can be done in parallel with 3-7 + +--- + +## Metadata + +- **Investigated by**: Claude +- **Timestamp**: 2026-04-09T12:00:00Z +- **Artifact**: `.claude/PRPs/issues/issue-978.md` From feafc6baafc4c286e65c1e5497f41bb6855eb78a Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Thu, 9 Apr 2026 20:14:05 +0300 Subject: [PATCH 2/4] feat: add `archon serve` command for one-command web UI install (#978) Extract `startServer(opts)` from server's monolithic `main()` into an exported function with `ServerOptions` (webDistPath, port, skipPlatformAdapters). Add `import.meta.main` guard so the file still works as a standalone script for `bun dev`. Create `archon serve` CLI command that lazily downloads a pre-built web UI tarball from GitHub releases on first run, verifies SHA-256 checksum, extracts atomically, then starts the full server. Cached per version in `~/.archon/web-dist//`. Update release CI to build the web UI, package it as `archon-web.tar.gz`, and include in release checksums. --- .claude/PRPs/issues/issue-978.md | 538 ------------------------ .github/workflows/release.yml | 18 +- bun.lock | 2 + packages/cli/package.json | 4 +- packages/cli/src/cli.ts | 14 +- packages/cli/src/commands/serve.test.ts | 76 ++++ packages/cli/src/commands/serve.ts | 117 ++++++ packages/cli/tsconfig.json | 21 +- packages/paths/src/archon-paths.ts | 8 + packages/paths/src/index.ts | 1 + packages/server/src/index.ts | 357 ++++++++-------- 11 files changed, 443 insertions(+), 713 deletions(-) delete mode 100644 .claude/PRPs/issues/issue-978.md create mode 100644 packages/cli/src/commands/serve.test.ts create mode 100644 packages/cli/src/commands/serve.ts diff --git a/.claude/PRPs/issues/issue-978.md b/.claude/PRPs/issues/issue-978.md deleted file mode 100644 index 1d11f53601..0000000000 --- a/.claude/PRPs/issues/issue-978.md +++ /dev/null @@ -1,538 +0,0 @@ -# Investigation: One-command web UI install via `archon serve` - -**Issue**: #978 (https://github.com/coleam00/Archon/issues/978) -**Type**: ENHANCEMENT -**Investigated**: 2026-04-09T12:00:00Z - -### Assessment - -| Metric | Value | Reasoning | -| ---------- | ------ | --------------------------------------------------------------------------------------------------------------------------------- | -| Priority | MEDIUM | High user value (removes clone+build friction), but existing Docker path and clone path work; not blocking other work | -| Complexity | HIGH | 8+ files across CLI, server, CI, build scripts; server refactor is the hardest part — `main()` is 600 lines with no library API | -| Confidence | HIGH | Clear codebase analysis, all integration points mapped, no unknowns in the download/extract path; server refactor scope is bounded | - ---- - -## Problem Statement - -The compiled Archon CLI binary includes only `packages/cli/src/cli.ts` — no server, no web UI, no `archon serve` command. Users who want the web UI must clone the entire monorepo, install Bun, run `bun install` (2274 packages), and `bun dev`. There is no one-command path to get a working web UI from the binary install. - ---- - -## Analysis - -### Change Rationale - -The web UI is the most discoverable part of the product, but it's behind the highest friction install path. The proposed approach — lazy-fetching a pre-built web UI tarball from GitHub releases on first `archon serve` — keeps the CLI binary small for CLI-only users while giving web UI users a one-command experience: `brew install coleam00/archon/archon && archon serve`. - -### Key Design Decision: Server as Library vs Embedded Mini-Server - -The current server (`packages/server/src/index.ts`) is a 721-line script with a monolithic `main()` function (line 129-718). It has no `startServer()` export and cannot be imported as a library. Two approaches: - -**Option A: Full server refactor** — Extract `main()` into an exported `startServer(opts)` function, make `@archon/server` a dependency of `@archon/cli`, compile the full server into the binary. Binary grows from ~50MB to ~65MB. All platform adapters (Slack, Telegram, GitHub, Discord) would be compiled in. - -**Option B: Minimal embedded server** — Create a lightweight Hono server in `packages/cli/src/commands/serve.ts` that only registers API routes + static serving. No platform adapters. Binary stays closer to current size. Uses `registerApiRoutes()` (already exported from `packages/server/src/routes/api.ts:837`) as the core building block. - -**Recommendation: Option A (full refactor)** because: -- Option B would duplicate server initialization logic and diverge over time -- Platform adapters are only instantiated when env vars are present (all conditional, see `index.ts:296-459`) — zero cost if not configured -- The binary size increase (~15MB) is acceptable -- Users get the full server experience, not a subset - -### Affected Files - -| File | Lines | Action | Description | -|------|-------|--------|-------------| -| `packages/cli/src/commands/serve.ts` | NEW | CREATE | `archon serve` command: download web-dist, start server | -| `packages/cli/src/cli.ts` | 57-82, 231, 266+ | UPDATE | Add `'serve'` to `noGitCommands`, add `case 'serve'` | -| `packages/cli/package.json` | deps | UPDATE | Add `@archon/server` and `@archon/adapters` as dependencies | -| `packages/server/src/index.ts` | 129-718 | UPDATE | Extract `main()` into exported `startServer(opts)` | -| `packages/server/src/index.ts` | 579-593 | UPDATE | Accept `webDistPath` parameter instead of computing from `import.meta.dir` | -| `.github/workflows/release.yml` | 140-173 | UPDATE | Add web UI build + tarball upload step | -| `scripts/build-binaries.sh` | — | NONE | No change needed — `bun build --compile` follows imports automatically | -| `packages/paths/src/archon-paths.ts` | — | UPDATE | Add `getWebDistPath(version)` helper | -| Tests | NEW | CREATE | Cover download, checksum, extraction, server startup from CLI | - -### Integration Points - -- `packages/cli/src/cli.ts:57-82` imports all commands after dotenv setup -- `packages/server/src/routes/api.ts:837` exports `registerApiRoutes(app, webAdapter, lockManager)` — the only reusable server building block -- `packages/paths/src/bundled-build.ts` provides `BUNDLED_VERSION` for constructing release URLs -- `packages/paths/src/archon-paths.ts:56-74` provides `getArchonHome()` for cache location -- `packages/server/src/index.ts:581-593` resolves `webDistPath` from `import.meta.dir` — needs parameterization -- `.github/workflows/release.yml:163-173` publishes release assets via `softprops/action-gh-release@v2` - -### Git History - -- **Server last touched**: `4b2bcb0e` (env-leak-gate polish) — active development area -- **CLI last touched**: `dddff870` (embed git commit hash in version) — recent changes -- **Build scripts**: `9adc54af` (wire release workflow to build-binaries.sh) — recently stabilized - ---- - -## Implementation Plan - -### Step 1: Extract `startServer(opts)` from server's `main()` - -**File**: `packages/server/src/index.ts` -**Lines**: 129-718 -**Action**: UPDATE - -**Current code (simplified):** -```typescript -async function main(): Promise { - // 600 lines of initialization, adapter creation, route registration, Bun.serve() -} - -main().catch(error => { ... process.exit(1); }); -``` - -**Required change:** - -```typescript -export interface ServerOptions { - /** Override the web dist path (for CLI binary with downloaded web-dist) */ - webDistPath?: string; - /** Override the port */ - port?: number; - /** Skip platform adapter initialization (CLI serve mode) */ - skipPlatformAdapters?: boolean; -} - -export async function startServer(opts: ServerOptions = {}): Promise { - // Move entire main() body here - // Replace webDistPath computation (lines 584-588) with: - // opts.webDistPath ?? pathModule.join(pathModule.dirname(pathModule.dirname(import.meta.dir)), 'web', 'dist') - // Replace port with: opts.port ?? getPort() - // Wrap platform adapter blocks with: if (!opts.skipPlatformAdapters) { ... } -} - -// Keep backward compat: script entry point still works -if (import.meta.main) { - startServer().catch(error => { - getLog().fatal({ error: error instanceof Error ? error.message : String(error) }, 'startup_failed'); - process.exit(1); - }); -} -``` - -**Why**: Makes the server importable as a library. `import.meta.main` guard ensures the file still works as a standalone script for `bun dev`. - ---- - -### Step 2: Add `getWebDistDir()` path helper - -**File**: `packages/paths/src/archon-paths.ts` -**Action**: UPDATE - -**Add function:** -```typescript -/** - * 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); -} -``` - -**Why**: Centralizes the cache location logic, consistent with existing `getArchonHome()` patterns. - ---- - -### Step 3: Create `archon serve` command - -**File**: `packages/cli/src/commands/serve.ts` -**Action**: CREATE - -```typescript -import { existsSync } from 'fs'; -import { createLogger, getWebDistDir } from '@archon/paths'; -import { BUNDLED_IS_BINARY, BUNDLED_VERSION } from '@archon/paths/bundled-build'; - -const log = createLogger('cli.serve'); - -const GITHUB_REPO = 'coleam00/Archon'; - -interface ServeOptions { - port?: number; - downloadOnly?: boolean; -} - -export async function serveCommand(opts: ServeOptions): Promise { - const version = BUNDLED_IS_BINARY ? BUNDLED_VERSION : 'dev'; - - if (version === 'dev') { - console.error('Error: `archon serve` is for compiled binaries only.'); - console.error('For development, use: bun run dev'); - return 1; - } - - const webDistDir = getWebDistDir(version); - - if (!existsSync(webDistDir)) { - await downloadWebDist(version, webDistDir); - } - - if (opts.downloadOnly) { - log.info({ webDistDir }, 'web_dist.download_completed'); - console.log(`Web UI downloaded to: ${webDistDir}`); - return 0; - } - - // Import server and start - const { startServer } = await import('@archon/server'); - await startServer({ - webDistPath: webDistDir, - port: opts.port, - skipPlatformAdapters: false, // Start all configured adapters - }); - - // 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`; - - console.log(`Web UI not found locally — downloading from release v${version}...`); - - // Download checksums - const checksumsRes = await fetch(checksumsUrl); - 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); - 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`; - const { mkdirSync, renameSync, rmSync } = await import('fs'); - - // 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), - }); - const exitCode = await proc.exited; - if (exitCode !== 0) { - rmSync(tmpDir, { recursive: true, force: true }); - throw new Error(`tar extraction failed with exit code ${exitCode}`); - } - - // Atomic move - renameSync(tmpDir, targetDir); - console.log(`Extracted to ${targetDir}`); -} - -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) { - return parts[0]; - } - } - throw new Error(`Checksum not found for ${filename} in checksums.txt`); -} -``` - -**Why**: Self-contained command following existing CLI patterns. Atomic extraction prevents half-broken state. Checksum verification prevents supply chain attacks. - ---- - -### Step 4: Wire `serve` into CLI command dispatch - -**File**: `packages/cli/src/cli.ts` -**Lines**: 57-82, 231, 266+ -**Action**: UPDATE - -**Change 1** — Add import (after line 82): -```typescript -import { serveCommand } from './commands/serve.js'; -``` - -**Change 2** — Add to `noGitCommands` (line 231): -```typescript -const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue', 'serve']; -``` - -**Change 3** — Add case in switch (after the existing `case 'continue'` block): -```typescript -case 'serve': { - const servePort = values.port ? Number(values.port) : undefined; - const downloadOnly = Boolean(values['download-only']); - return await serveCommand({ port: servePort, downloadOnly }); -} -``` - -**Change 4** — Add `--port` and `--download-only` to `parseArgs` options: -```typescript -port: { type: 'string' }, -'download-only': { type: 'boolean', default: false }, -``` - -**Change 5** — Update `printUsage()` to include `serve`: -``` - serve Start the web UI server (downloads web UI on first run) - --port Override server port (default: 3090) - --download-only Download web UI without starting the server -``` - -**Why**: Follows exact patterns of existing commands. `serve` doesn't need a git repo. - ---- - -### Step 5: Add `@archon/server` dependency to CLI package - -**File**: `packages/cli/package.json` -**Action**: UPDATE - -Add to `dependencies`: -```json -"@archon/server": "workspace:*", -"@archon/adapters": "workspace:*" -``` - -**Why**: The CLI needs to import `startServer` from `@archon/server`. `@archon/adapters` is a transitive dependency of `@archon/server` and should be explicit. - ---- - -### Step 6: Update release CI to build and publish web UI tarball - -**File**: `.github/workflows/release.yml` -**Action**: UPDATE - -**Add new job** (or add steps to existing `release` job, after artifact download): - -```yaml - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - 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-* archon-web.tar.gz > checksums.txt - cat checksums.txt -``` - -**Update** the `files:` block in the release step: -```yaml - files: | - dist/archon-* - dist/archon-web.tar.gz - dist/checksums.txt -``` - -**Why**: Publishes a single platform-independent web UI tarball alongside the existing per-platform binaries. Checksums cover all artifacts. - ---- - -### Step 7: Add/Update Tests - -**File**: `packages/cli/src/commands/serve.test.ts` -**Action**: CREATE - -**Test cases to add:** - -```typescript -describe('serveCommand', () => { - it('should reject in dev mode (non-binary)', () => { - // Mock BUNDLED_IS_BINARY = false - // Expect exit code 1 with "compiled binaries only" message - }); - - it('should download web-dist when not cached', () => { - // Mock fetch to return tarball + checksums - // Verify extraction to correct path - }); - - it('should skip download when already cached', () => { - // Pre-create the web-dist dir - // Verify no fetch calls - }); - - it('should fail on checksum mismatch', () => { - // Mock fetch with wrong checksum - // Expect error, no leftover .tmp dir - }); - - it('should handle network failure gracefully', () => { - // Mock fetch to throw - // Expect actionable error message - }); - - it('should support --download-only', () => { - // Mock fetch, run with downloadOnly: true - // Verify no startServer call - }); -}); - -describe('parseChecksum', () => { - it('should extract hash for matching filename', () => { - // Known checksums.txt format - }); - - it('should throw for missing filename', () => { - // checksums.txt without the expected entry - }); -}); -``` - ---- - -## Patterns to Follow - -**From codebase — mirror these exactly:** - -```typescript -// SOURCE: packages/cli/src/commands/version.ts:79-88 -// Pattern for binary detection -if (BUNDLED_IS_BINARY) { - version = BUNDLED_VERSION; - gitCommit = BUNDLED_GIT_COMMIT; -} else { - const devInfo = await getDevVersion(); - version = devInfo.version; - gitCommit = await getDevGitCommit(); -} -``` - -```typescript -// SOURCE: packages/paths/src/archon-paths.ts:56-74 -// Pattern for path resolution with ARCHON_HOME override -export function getArchonHome(): string { - if (isDocker()) { - return '/.archon'; - } - const envHome = process.env.ARCHON_HOME; - if (envHome) { /* ... */ return expandTilde(envHome); } - return join(homedir(), '.archon'); -} -``` - -```typescript -// SOURCE: packages/server/src/index.ts:579-593 -// Pattern for static file serving (to be parameterized) -if (process.env.NODE_ENV === 'production' || !process.env.WEB_UI_DEV) { - const { serveStatic } = await import('hono/bun'); - app.use('/assets/*', serveStatic({ root: webDistPath })); - app.get('*', serveStatic({ root: webDistPath, path: 'index.html' })); -} -``` - ---- - -## Edge Cases & Risks - -| Risk/Edge Case | Mitigation | -|---------------|------------| -| Server refactor breaks `bun dev` | `import.meta.main` guard keeps script-mode working; test both paths | -| Binary size bloat from including server | Monitor: current ~50MB, expected ~65MB. Acceptable for the value. | -| Tarball extraction fails (permissions, disk space) | Atomic extraction (`.tmp` → rename); clean up on failure; clear error message | -| GitHub release rate limiting | `fetch` will return 403 — surface the error with retry suggestion | -| Air-gapped environments | `--download-only` allows pre-caching; future `--web-dist ` for offline | -| Version mismatch (binary v0.3.2 but no release exists yet) | Fail with "release not found" — only happens if someone builds from source with wrong version | -| `tar` not available on system | Available on all macOS/Linux; for Windows, use Bun's built-in tar or `decompress` | -| Concurrent `archon serve` calls during first download | Atomic rename prevents corruption; second process sees complete dir or retries | -| `@archon/server` import increases CLI startup time | Use dynamic `await import()` in serve command only — other commands unaffected | - ---- - -## Validation - -### Automated Checks - -```bash -bun run type-check -bun run test -bun run lint -bun run validate # Full pre-PR validation -``` - -### Manual Verification - -1. Run `bun run dev` — verify server still starts normally (script mode preserved) -2. Build binary: `VERSION=test scripts/build-binaries.sh` — verify it compiles -3. Run binary with `archon serve` — verify download + extraction + server start -4. Run binary with `archon serve --download-only` — verify download without server -5. Run binary with `archon serve` a second time — verify cached (no download) -6. Run `archon workflow list` — verify no startup time regression from server dep -7. Verify `archon serve --port 4000` — verify port override works - ---- - -## Scope Boundaries - -**IN SCOPE:** -- Server library refactor (extract `startServer()`) -- `archon serve` CLI command with download + checksum + extract -- `--port` and `--download-only` flags -- Release CI changes to build and publish `archon-web.tar.gz` -- Path helper for web-dist cache location -- Tests for download/extract/checksum logic - -**OUT OF SCOPE (do not touch):** -- `bun dev` workflow — stays as-is for contributors -- Docker image — orthogonal, not affected -- CDN mirroring — GitHub releases sufficient for now -- `archon serve --web-version=latest` — defer to future issue -- `archon serve --offline --web-dist=./path` — defer (can add later) -- Homebrew formula changes — just update docs, no formula change needed -- Auto-update of cached web-dist — version-keyed dirs handle this naturally -- Deprecating clone-and-bun-dev — keep for contributors -- Platform adapter lazy loading optimization — all adapters already conditional on env vars - ---- - -## Implementation Order - -The steps have a strict dependency chain: - -1. **Step 2** (path helper) — no deps, can go first -2. **Step 1** (server refactor) — the hardest part, do early -3. **Step 5** (CLI package.json dep) — needed before Step 3 -4. **Step 3** (serve command) — depends on Steps 1, 2, 5 -5. **Step 4** (CLI wiring) — depends on Step 3 -6. **Step 7** (tests) — depends on Steps 3, 4 -7. **Step 6** (CI changes) — independent, can be done in parallel with 3-7 - ---- - -## Metadata - -- **Investigated by**: Claude -- **Timestamp**: 2026-04-09T12:00:00Z -- **Artifact**: `.claude/PRPs/issues/issue-978.md` 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/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..a76b420938 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 ? 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..2e13780001 --- /dev/null +++ b/packages/cli/src/commands/serve.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, mock, beforeEach, 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', () => { + it('should extract hash for matching filename', () => { + const checksums = [ + 'abc123def456 archon-linux-x64', + 'deadbeef1234 archon-web.tar.gz', + 'cafe0000babe archon-darwin-arm64', + ].join('\n'); + + expect(parseChecksum(checksums, 'archon-web.tar.gz')).toBe('deadbeef1234'); + }); + + it('should handle single-space separator', () => { + const checksums = 'abc123 archon-web.tar.gz\n'; + expect(parseChecksum(checksums, 'archon-web.tar.gz')).toBe('abc123'); + }); + + it('should throw for missing filename', () => { + const checksums = 'abc123 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 = '\nabc123 archon-web.tar.gz\n\n'; + expect(parseChecksum(checksums, 'archon-web.tar.gz')).toBe('abc123'); + }); +}); + +describe('serveCommand', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + 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.' + ); + consoleErrorSpy.mockRestore(); + }); + + it('should reject with downloadOnly in dev mode', async () => { + const exitCode = await serveCommand({ downloadOnly: true }); + expect(exitCode).toBe(1); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts new file mode 100644 index 0000000000..3858e79fe1 --- /dev/null +++ b/packages/cli/src/commands/serve.ts @@ -0,0 +1,117 @@ +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'; + +export interface ServeOptions { + port?: number; + downloadOnly?: boolean; +} + +export async function serveCommand(opts: ServeOptions): Promise { + const version = BUNDLED_IS_BINARY ? BUNDLED_VERSION : 'dev'; + + if (version === 'dev') { + console.error('Error: `archon serve` is for compiled binaries only.'); + console.error('For development, use: bun run dev'); + return 1; + } + + const webDistDir = getWebDistDir(version); + + if (!existsSync(webDistDir)) { + await downloadWebDist(version, webDistDir); + } 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) + const { startServer } = await import('@archon/server'); + await startServer({ + webDistPath: webDistDir, + port: opts.port, + skipPlatformAdapters: false, + }); + + // 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`; + + console.log(`Web UI not found locally — downloading from release v${version}...`); + + // Download checksums + const checksumsRes = await fetch(checksumsUrl); + 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); + 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), + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + rmSync(tmpDir, { recursive: true, force: true }); + throw new Error(`tar extraction failed with exit code ${exitCode}`); + } + + // Atomic move into place + mkdirSync(targetDir.substring(0, targetDir.lastIndexOf('/')), { recursive: true }); + renameSync(tmpDir, targetDir); + console.log(`Extracted to ${targetDir}`); +} + +/** + * 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) { + return parts[0]; + } + } + 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/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..23bcf08a6b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -126,7 +126,16 @@ 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) */ + webDistPath?: string; + /** Override the port */ + port?: number; + /** Skip platform adapter initialization (CLI serve mode) */ + 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 +287,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 +598,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 +616,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 +642,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 +729,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); + }); +} From 2fc6af7ab54446f8f6b721b6cef10f613b8562cc Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Fri, 10 Apr 2026 13:26:35 +0300 Subject: [PATCH 3/4] fix: address review findings for archon serve command - Validate --port range (1-65535) and reject NaN before any other checks - Capture tar stderr for actionable extraction error messages - Add structured logging (download_started/download_failed/server_start_failed) - Post-extraction sanity check for index.html - Wrap renameSync with error context and tmpDir cleanup - Wrap fetch() calls to preserve URL context on network errors - Validate parseChecksum returns 64 hex chars - Set skipPlatformAdapters: true for standalone web UI mode - Improve ServerOptions/ServeOptions JSDoc - Move consoleErrorSpy cleanup to afterEach in tests - Add tests for port validation and malformed hash rejection - Update CLAUDE.md: CLI section, directory tree, package descriptions - Update README.md: mention archon serve for binary installs - Update docs-web: CLI reference, archon-directories --- CLAUDE.md | 10 ++- README.md | 2 +- packages/cli/src/cli.ts | 2 +- packages/cli/src/commands/serve.test.ts | 63 ++++++++++++--- packages/cli/src/commands/serve.ts | 76 ++++++++++++++++--- .../docs/reference/archon-directories.md | 5 ++ .../src/content/docs/reference/cli.md | 28 ++++++- packages/server/src/index.ts | 9 ++- 8 files changed, 163 insertions(+), 32 deletions(-) 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/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a76b420938..8d2fc02af1 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -541,7 +541,7 @@ async function main(): Promise { } case 'serve': { - const servePort = values.port ? Number(values.port) : undefined; + const servePort = values.port !== undefined ? Number(values.port) : undefined; const downloadOnly = Boolean(values['download-only']); return await serveCommand({ port: servePort, downloadOnly }); } diff --git a/packages/cli/src/commands/serve.test.ts b/packages/cli/src/commands/serve.test.ts index 2e13780001..99d9ec1cf6 100644 --- a/packages/cli/src/commands/serve.test.ts +++ b/packages/cli/src/commands/serve.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, mock, beforeEach, spyOn } from 'bun:test'; +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. @@ -20,23 +20,25 @@ mock.module('@archon/paths', () => ({ import { serveCommand, parseChecksum } from './serve'; describe('parseChecksum', () => { + const validHash = 'a'.repeat(64); + it('should extract hash for matching filename', () => { const checksums = [ - 'abc123def456 archon-linux-x64', - 'deadbeef1234 archon-web.tar.gz', - 'cafe0000babe archon-darwin-arm64', + `${'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('deadbeef1234'); + expect(parseChecksum(checksums, 'archon-web.tar.gz')).toBe(validHash); }); it('should handle single-space separator', () => { - const checksums = 'abc123 archon-web.tar.gz\n'; - expect(parseChecksum(checksums, 'archon-web.tar.gz')).toBe('abc123'); + 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 = 'abc123 archon-linux-x64\n'; + const checksums = `${validHash} archon-linux-x64\n`; expect(() => parseChecksum(checksums, 'archon-web.tar.gz')).toThrow( 'Checksum not found for archon-web.tar.gz' ); @@ -47,8 +49,22 @@ describe('parseChecksum', () => { }); it('should skip blank lines', () => { - const checksums = '\nabc123 archon-web.tar.gz\n\n'; - expect(parseChecksum(checksums, 'archon-web.tar.gz')).toBe('abc123'); + 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' + ); }); }); @@ -59,18 +75,41 @@ describe('serveCommand', () => { 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.' ); - consoleErrorSpy.mockRestore(); }); it('should reject with downloadOnly in dev mode', async () => { const exitCode = await serveCommand({ downloadOnly: true }); expect(exitCode).toBe(1); - consoleErrorSpy.mockRestore(); + }); + + 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); }); }); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 3858e79fe1..0dfdee0b4d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -6,11 +6,21 @@ const log = createLogger('cli.serve'); const GITHUB_REPO = 'coleam00/Archon'; 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; + } + const version = BUNDLED_IS_BINARY ? BUNDLED_VERSION : 'dev'; if (version === 'dev') { @@ -22,7 +32,14 @@ export async function serveCommand(opts: ServeOptions): Promise { const webDistDir = getWebDistDir(version); if (!existsSync(webDistDir)) { - await downloadWebDist(version, webDistDir); + try { + await downloadWebDist(version, webDistDir); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(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'); } @@ -34,12 +51,19 @@ export async function serveCommand(opts: ServeOptions): Promise { } // Import server and start (dynamic import keeps CLI startup fast for other commands) - const { startServer } = await import('@archon/server'); - await startServer({ - webDistPath: webDistDir, - port: opts.port, - skipPlatformAdapters: false, - }); + try { + const { startServer } = await import('@archon/server'); + await startServer({ + webDistPath: webDistDir, + port: opts.port, + skipPlatformAdapters: true, + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(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; @@ -49,10 +73,15 @@ async function downloadWebDist(version: string, targetDir: string): Promise { + 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}` @@ -63,7 +92,9 @@ async function downloadWebDist(version: string, targetDir: string): Promise { + 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}`); } @@ -89,16 +120,33 @@ async function downloadWebDist(version: string, targetDir: string): Promise= 2 && parts[1] === filename) { - return parts[0]; + 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/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/server/src/index.ts b/packages/server/src/index.ts index 23bcf08a6b..04633bc8ad 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -127,11 +127,14 @@ export function handleUnhandledRejection(reason: unknown): void { } export interface ServerOptions { - /** Override the web dist path (for CLI binary with downloaded web-dist) */ + /** + * 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 */ + /** Override the port. Range: 1–65535. */ port?: number; - /** Skip platform adapter initialization (CLI serve mode) */ + /** Run in standalone web-only mode (no Telegram/Slack/GitHub/Discord adapters). */ skipPlatformAdapters?: boolean; } From a1b9096b5c0edb2897ade05851170f61655b9720 Mon Sep 17 00:00:00 2001 From: Rasmus Widing Date: Fri, 10 Apr 2026 13:30:45 +0300 Subject: [PATCH 4/4] refactor: simplify serve command implementation - Use BUNDLED_IS_BINARY directly instead of version === 'dev' sentinel - Extract toError() helper for repeated error normalization - Use dirname() instead of manual substring/lastIndexOf - Extract cleanupAndThrow() for repeated rmSync + throw pattern - Add missing assertion on port 0 test for consistency --- packages/cli/src/commands/serve.test.ts | 3 +++ packages/cli/src/commands/serve.ts | 32 +++++++++++++++---------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/serve.test.ts b/packages/cli/src/commands/serve.test.ts index 99d9ec1cf6..df1dead454 100644 --- a/packages/cli/src/commands/serve.test.ts +++ b/packages/cli/src/commands/serve.test.ts @@ -111,5 +111,8 @@ describe('serveCommand', () => { 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 index 0dfdee0b4d..2db1d468c1 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1,3 +1,4 @@ +import { dirname } from 'path'; import { existsSync, mkdirSync, renameSync, rmSync } from 'fs'; import { createLogger, getWebDistDir, BUNDLED_IS_BINARY, BUNDLED_VERSION } from '@archon/paths'; @@ -5,6 +6,10 @@ 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; @@ -21,21 +26,20 @@ export async function serveCommand(opts: ServeOptions): Promise { return 1; } - const version = BUNDLED_IS_BINARY ? BUNDLED_VERSION : 'dev'; - - if (version === 'dev') { + 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 = err instanceof Error ? err : new Error(String(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; @@ -59,7 +63,7 @@ export async function serveCommand(opts: ServeOptions): Promise { skipPlatformAdapters: true, }); } catch (err) { - const error = err instanceof Error ? err : new Error(String(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; @@ -125,31 +129,35 @@ async function downloadWebDist(version: string, targetDir: string): Promise ` or ` `