Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 163 to 167
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Duplicate checksum entry for archon-web.tar.gz.

The glob archon-* already matches archon-web.tar.gz (since it starts with "archon-"), so explicitly adding it results in a duplicate line in checksums.txt. This could cause issues with checksum verification if the parser doesn't handle duplicates gracefully.

🔧 Proposed fix
       - name: Generate checksums
         run: |
           cd dist
-          sha256sum archon-* archon-web.tar.gz > checksums.txt
+          sha256sum archon-* > checksums.txt
           cat checksums.txt
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 163 - 167, In the "Generate
checksums" step the sha256sum invocation lists both the glob archon-* and the
explicit archon-web.tar.gz which duplicates that file; update the sha256sum
command in the Generate checksums step to remove the explicit archon-web.tar.gz
so only the glob is used (e.g., use sha256sum archon-* > checksums.txt) ensuring
each file appears once in checksums.txt.


- name: Get version
Expand All @@ -170,6 +185,7 @@ jobs:
generate_release_notes: true
files: |
dist/archon-*
dist/archon-web.tar.gz
dist/checksums.txt
body: |
## Installation
Expand Down
10 changes: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,11 @@ bun run cli validate commands my-command # Single command
bun run cli complete <branch-name>
bun run cli complete <branch-name> --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
```
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -530,6 +535,7 @@ curl http://localhost:3637/api/conversations/<conversationId>/messages
│ │ ├── runs/{id}/ # Per-run artifacts ($ARTIFACTS_DIR)
│ │ └── uploads/{convId}/ # Web UI file uploads (ephemeral)
│ └── logs/ # Workflow execution logs
├── web-dist/<version>/ # Cached web UI dist (archon serve, binary only)
├── archon.db # SQLite database (when DATABASE_URL not set)
└── config.yaml # Global configuration (non-secrets)
```
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -110,6 +111,7 @@ Commands:
isolation cleanup --merged Remove environments with branches merged into main
continue <branch> [msg] Continue work on an existing worktree with prior context
complete <branch> [...] 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
Expand All @@ -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 <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?"
Expand Down Expand Up @@ -194,6 +198,8 @@ async function main(): Promise<number> {
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
Expand Down Expand Up @@ -228,7 +234,7 @@ async function main(): Promise<number> {
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 {
Expand Down Expand Up @@ -534,6 +540,12 @@ async function main(): Promise<number> {
break;
}

case 'serve': {
const servePort = values.port !== undefined ? Number(values.port) : undefined;
const downloadOnly = Boolean(values['download-only']);
return await serveCommand({ port: servePort, downloadOnly });
}
Comment on lines +543 to +547
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing validation for invalid --port values.

If the user passes a non-numeric port (e.g., --port abc), Number(values.port) produces NaN, which would be passed to the server and could cause a cryptic error.

🔧 Proposed fix with validation
       case 'serve': {
         const servePort = values.port ? Number(values.port) : undefined;
+        if (servePort !== undefined && (Number.isNaN(servePort) || servePort <= 0 || servePort > 65535)) {
+          console.error('Error: --port must be a valid port number (1-65535)');
+          return 1;
+        }
         const downloadOnly = Boolean(values['download-only']);
         return await serveCommand({ port: servePort, downloadOnly });
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case 'serve': {
const servePort = values.port ? Number(values.port) : undefined;
const downloadOnly = Boolean(values['download-only']);
return await serveCommand({ port: servePort, downloadOnly });
}
case 'serve': {
const servePort = values.port ? Number(values.port) : undefined;
if (servePort !== undefined && (Number.isNaN(servePort) || servePort <= 0 || servePort > 65535)) {
console.error('Error: --port must be a valid port number (1-65535)');
return 1;
}
const downloadOnly = Boolean(values['download-only']);
return await serveCommand({ port: servePort, downloadOnly });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/cli.ts` around lines 543 - 547, The serve subcommand is
passing Number(values.port) directly which yields NaN for non-numeric inputs;
update the parsing in the case 'serve' block to validate values.port: if
provided, attempt to parse it to an integer, check Number.isInteger and that it
falls within the valid TCP port range (1–65535), and if invalid throw or return
a clear user-facing error (or exit) instead of passing NaN to serveCommand;
reference the variables servePort, values.port and call site serveCommand({
port: servePort, downloadOnly }) so the validated numeric port (or undefined) is
passed.


default:
if (command === undefined) {
console.error('Missing command');
Expand Down
118 changes: 118 additions & 0 deletions packages/cli/src/commands/serve.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof spyOn>;

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')
);
});
});
Loading
Loading