feat: rename server package to hub

Rename the `server/` directory to `hub/` and update all references
across CLI, docs, web, and workspace configuration.
This commit is contained in:
weishu
2026-01-27 19:51:21 +08:00
parent 10fe9f0cd9
commit 37e10a831b
125 changed files with 301 additions and 285 deletions
+7 -7
View File
@@ -2,10 +2,10 @@
## Project Overview
HAPI CLI (`hapi`) is a command-line tool that wraps Claude Code to enable remote control and session sharing via `hapi-server` (Telegram Bot + Mini App). It's part of a two-component system:
HAPI CLI (`hapi`) is a command-line tool that wraps Claude Code to enable remote control and session sharing via `hapi-hub` (Telegram Bot + Mini App). It's part of a two-component system:
1. **hapi** (this project) - CLI wrapper for Claude Code
2. **hapi-server** - Public server (Socket.IO + REST + SQLite) + Telegram Mini App
2. **hapi-hub** - Hub service (Socket.IO + REST + SQLite) + Telegram Mini App
## Code Style Preferences
@@ -96,29 +96,29 @@ User interface components.
## Data Flow
1. **Authentication**:
- Use `CLI_API_TOKEN` to authenticate to `hapi-server` (REST + Socket.IO)
- Use `CLI_API_TOKEN` to authenticate to `hapi-hub` (REST + Socket.IO)
2. **Session Creation**:
- Create/load session via `POST /cli/sessions` → Establish Socket.IO `/cli` connection
3. **Message Flow**:
- Local mode: terminal/SDK → hapi CLI → hapi-server → Telegram Mini App
- Local mode: terminal/SDK → hapi CLI → hapi-hub → Telegram Mini App
4. **Permission Handling**:
- Claude requests permission → hapi CLI exposes RPC handlers → Mini App calls REST → hapi-server relays RPC to hapi CLI
- Claude requests permission → hapi CLI exposes RPC handlers → Mini App calls REST → hapi-hub relays RPC to hapi CLI
## Key Design Decisions
1. **File-based logging**: Prevents interference with Claude's terminal UI
2. **Dual Claude integration**: Process spawning for interactive, SDK for remote
3. **No E2E encryption**: Use HTTPS/TLS for `hapi-server` deployments
3. **No E2E encryption**: Use HTTPS/TLS for `hapi-hub` deployments
4. **Session persistence**: Allows resuming sessions across restarts
5. **Optimistic concurrency**: Handles distributed state updates gracefully
## Security Considerations
- `CLI_API_TOKEN` is a shared secret; treat it like a password.
- No end-to-end encryption: use HTTPS/TLS for `hapi-server` deployments.
- No end-to-end encryption: use HTTPS/TLS for `hapi-hub` deployments.
- Session isolation through unique session IDs.
## Dependencies
+9 -8
View File
@@ -1,10 +1,10 @@
# hapi CLI
Run Claude Code, Codex, or Gemini sessions from your terminal and control them remotely through the hapi server.
Run Claude Code, Codex, or Gemini sessions from your terminal and control them remotely through the hapi hub.
## What it does
- Starts Claude Code sessions and registers them with hapi-server.
- Starts Claude Code sessions and registers them with hapi-hub.
- Starts Codex mode for OpenAI-based sessions.
- Starts Gemini mode via ACP (Anthropic Code Plugins).
- Provides an MCP stdio bridge for external tools.
@@ -13,7 +13,7 @@ Run Claude Code, Codex, or Gemini sessions from your terminal and control them r
## Typical flow
1. Start the server and set env vars (see ../server/README.md).
1. Start the hub and set env vars (see ../hub/README.md).
2. Set the same CLI_API_TOKEN on this machine or run `hapi auth login`.
3. Run `hapi` to start a session.
4. Use the web app or Telegram Mini App to monitor and control.
@@ -25,7 +25,7 @@ Run Claude Code, Codex, or Gemini sessions from your terminal and control them r
- `hapi` - Start a Claude Code session (passes through Claude CLI flags). See `src/index.ts`.
- `hapi codex` - Start Codex mode. See `src/codex/runCodex.ts`.
- `hapi gemini` - Start Gemini mode via ACP. See `src/agent/runners/runAgentSession.ts`.
Note: Gemini runs in remote mode only; it waits for messages from the server UI/Telegram.
Note: Gemini runs in remote mode only; it waits for messages from the hub UI/Telegram.
### Authentication
@@ -58,7 +58,8 @@ See `src/ui/doctor.ts`.
### Other
- `hapi mcp` - Start MCP stdio bridge. See `src/codex/happyMcpStdioBridge.ts`.
- `hapi server` - Start the bundled server (single binary workflow).
- `hapi hub` - Start the bundled hub (single binary workflow).
- `hapi server` - Alias for `hapi hub`.
## Configuration
@@ -66,8 +67,8 @@ See `src/configuration.ts` for all options.
### Required
- `CLI_API_TOKEN` - Shared secret; must match the server. Can be set via env or `~/.hapi/settings.json` (env wins).
- `HAPI_API_URL` - Server base URL (default: http://localhost:3006).
- `CLI_API_TOKEN` - Shared secret; must match the hub. Can be set via env or `~/.hapi/settings.json` (env wins).
- `HAPI_API_URL` - Hub base URL (default: http://localhost:3006).
### Optional
@@ -123,5 +124,5 @@ bun run build:single-exe
## Related docs
- `../server/README.md`
- `../hub/README.md`
- `../web/README.md`
+4 -4
View File
@@ -126,7 +126,7 @@ function resolveOutdir(projectRoot: string, outdir: string): string {
}
function writeStubEmbeddedAssets(workspaceRoot: string): void {
const outputPath = join(workspaceRoot, 'server', 'src', 'web', 'embeddedAssets.generated.ts');
const outputPath = join(workspaceRoot, 'hub', 'src', 'web', 'embeddedAssets.generated.ts');
const contents = [
'// This file is generated by cli/scripts/build-executable.ts when --with-web-assets is not used.',
'// It intentionally contains no embedded assets.',
@@ -153,16 +153,16 @@ function isStubEmbeddedAssets(manifestPath: string): boolean {
}
function ensureEmbeddedAssetsManifest(workspaceRoot: string, includeWebAssets: boolean): void {
const manifestPath = join(workspaceRoot, 'server', 'src', 'web', 'embeddedAssets.generated.ts');
const manifestPath = join(workspaceRoot, 'hub', 'src', 'web', 'embeddedAssets.generated.ts');
if (includeWebAssets) {
if (!existsSync(manifestPath)) {
throw new Error(
'Missing embedded web asset manifest. Run `bun run build:web` and `cd server && bun run generate:embedded-web-assets`, or `bun run build:single-exe` from the repo root.'
'Missing embedded web asset manifest. Run `bun run build:web` and `cd hub && bun run generate:embedded-web-assets`, or `bun run build:single-exe` from the repo root.'
);
}
if (isStubEmbeddedAssets(manifestPath)) {
throw new Error(
'Embedded web asset manifest is a stub. Run `bun run build:web` and `cd server && bun run generate:embedded-web-assets`, or `bun run build:single-exe` from the repo root.'
'Embedded web asset manifest is a stub. Run `bun run build:web` and `cd hub && bun run generate:embedded-web-assets`, or `bun run build:single-exe` from the repo root.'
);
}
return;
+1 -1
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const cliRoot = join(scriptDir, '..');
const workspaceRoot = join(cliRoot, '..');
const outputPath = join(workspaceRoot, 'server', 'src', 'web', 'embeddedAssets.generated.ts');
const outputPath = join(workspaceRoot, 'hub', 'src', 'web', 'embeddedAssets.generated.ts');
const contents = [
'// This file is generated by cli/scripts/build-executable.ts when --with-web-assets is not used.',
'// It intentionally contains no embedded assets.',
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* WebSocket client for machine/runner communication with hapi-server
* WebSocket client for machine/runner communication with hapi-hub
*/
import { io, type Socket } from 'socket.io-client'
+1 -1
View File
@@ -13,7 +13,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
const exitFuture = new Future<void>();
const resumeSessionId = session.sessionId;
// Start hapi server for MCP bridge (same as remote mode)
// Start hapi hub for MCP bridge (same as remote mode)
const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client);
logger.debug(`[codex-local]: Started hapi MCP bridge server at ${happyServer.url}`);
+8 -7
View File
@@ -75,8 +75,9 @@ ${chalk.bold('Usage:')}
hapi mcp Start MCP stdio bridge
hapi connect (not available in direct-connect mode)
hapi notify (not available in direct-connect mode)
hapi server Start the API + web server
hapi server --relay Start with public relay
hapi hub Start the API + web hub
hapi hub --relay Start with public relay
hapi server Alias for hapi hub
hapi runner Manage background service that allows
to spawn new sessions away from your computer
hapi doctor System diagnostics & troubleshooting
@@ -146,9 +147,9 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')}
messageLower.includes('enotfound') ||
messageLower.includes('network error')
) {
console.error(chalk.yellow('Unable to connect to HAPI server'))
console.error(chalk.gray(` Server URL: ${configuration.apiUrl}`))
console.error(chalk.gray(' Please check your network connection or server status'))
console.error(chalk.yellow('Unable to connect to HAPI hub'))
console.error(chalk.gray(` Hub URL: ${configuration.apiUrl}`))
console.error(chalk.gray(' Please check your network connection or hub status'))
} else if (httpStatus === 403 && responseErrorText === 'Machine access denied') {
console.error(chalk.red('Machine access denied.'))
console.error(chalk.gray(' This machineId is already registered under a different namespace.'))
@@ -171,9 +172,9 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')}
if (serverProtocolVersion !== undefined && serverProtocolVersion !== PROTOCOL_VERSION) {
if (serverProtocolVersion < PROTOCOL_VERSION) {
console.error(chalk.yellow(` Hint: server protocol version (${serverProtocolVersion}) is behind CLI (${PROTOCOL_VERSION}). Please update the server.`))
console.error(chalk.yellow(` Hint: hub protocol version (${serverProtocolVersion}) is behind CLI (${PROTOCOL_VERSION}). Please update the hub.`))
} else {
console.error(chalk.yellow(` Hint: CLI protocol version (${PROTOCOL_VERSION}) is behind server (${serverProtocolVersion}). Please update the CLI.`))
console.error(chalk.yellow(` Hint: CLI protocol version (${PROTOCOL_VERSION}) is behind hub (${serverProtocolVersion}). Please update the CLI.`))
}
}
@@ -1,7 +1,7 @@
import chalk from 'chalk'
import type { CommandDefinition, CommandContext } from './types'
function parseServerArgs(args: string[]): { host?: string; port?: string } {
function parseHubArgs(args: string[]): { host?: string; port?: string } {
const result: { host?: string; port?: string } = {}
for (let i = 0; i < args.length; i++) {
@@ -20,12 +20,12 @@ function parseServerArgs(args: string[]): { host?: string; port?: string } {
return result
}
export const serverCommand: CommandDefinition = {
name: 'server',
export const hubCommand: CommandDefinition = {
name: 'hub',
requiresRuntimeAssets: true,
run: async (context: CommandContext) => {
try {
const { host, port } = parseServerArgs(context.commandArgs)
const { host, port } = parseHubArgs(context.commandArgs)
if (host) {
process.env.WEBAPP_HOST = host
@@ -33,7 +33,7 @@ export const serverCommand: CommandDefinition = {
if (port) {
process.env.WEBAPP_PORT = port
}
await import('../../../server/src/index')
await import('../../../hub/src/index')
} catch (error) {
console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error')
if (process.env.DEBUG) {
+1 -1
View File
@@ -6,7 +6,7 @@ export const notifyCommand: CommandDefinition = {
requiresRuntimeAssets: true,
run: async () => {
console.error(chalk.red('The `hapi notify` command is not available in direct-connect mode.'))
console.error(chalk.gray('Use Telegram notifications from hapi-server instead.'))
console.error(chalk.gray('Use Telegram notifications from hapi-hub instead.'))
process.exit(1)
}
}
+3 -2
View File
@@ -8,7 +8,7 @@ import { geminiCommand } from './gemini'
import { hookForwarderCommand } from './hookForwarder'
import { mcpCommand } from './mcp'
import { notifyCommand } from './notify'
import { serverCommand } from './server'
import { hubCommand } from './hub'
import type { CommandContext, CommandDefinition } from './types'
const COMMANDS: CommandDefinition[] = [
@@ -17,7 +17,8 @@ const COMMANDS: CommandDefinition[] = [
codexCommand,
geminiCommand,
mcpCommand,
serverCommand,
hubCommand,
{ ...hubCommand, name: 'server' },
hookForwarderCommand,
doctorCommand,
runnerCommand,
+6 -6
View File
@@ -280,7 +280,7 @@ All data is plain JSON over TLS; authentication is `CLI_API_TOKEN` (no end-to-en
### Test Environment
- Requires `.env.integration-test`
- Uses local hapi-server (http://localhost:3006)
- Uses local hapi-hub (http://localhost:3006)
- Separate `~/.hapi-dev-test` home directory
### Key Test Scenarios
@@ -296,7 +296,7 @@ All data is plain JSON over TLS; authentication is `CLI_API_TOKEN` (no end-to-en
# Machine Sync Architecture - Separated Metadata & Runner State
> Direct-connect note: the "server" is `hapi-server`, payloads are plain JSON (no base64/encryption),
> Direct-connect note: the "hub" is `hapi-hub`, payloads are plain JSON (no base64/encryption),
> and authentication uses `CLI_API_TOKEN` (REST `Authorization: Bearer ...` + Socket.IO `handshake.auth.token`).
## Data Structure (Similar to Session's metadata + agentState)
@@ -327,7 +327,7 @@ interface RunnerState {
Checks if machine ID exists in settings:
- If not: creates ID locally only (so sessions can reference it)
- Does NOT create machine on server - that's runner's job
- Does NOT create machine on hub - that's runner's job
- CLI doesn't manage machine details - all API & schema live in runner subpackage
## 2. Runner Startup - Initial Registration
@@ -444,10 +444,10 @@ socket.emit('machine-update-metadata', {
}, callback)
```
## 5. Mini App RPC Calls (via hapi-server)
## 5. Mini App RPC Calls (via hapi-hub)
The Telegram Mini App calls REST endpoints on `hapi-server` (for example `POST /api/machines/:id/spawn`).
`hapi-server` then relays those requests to the runner via Socket.IO `rpc-request` on the `/cli` namespace.
The Telegram Mini App calls REST endpoints on `hapi-hub` (for example `POST /api/machines/:id/spawn`).
`hapi-hub` then relays those requests to the runner via Socket.IO `rpc-request` on the `/cli` namespace.
RPC method naming (machine-scoped) uses a `${machineId}:` prefix, for example:
- `${machineId}:spawn-happy-session`
+3 -3
View File
@@ -11,8 +11,8 @@
*
* The integration test environment uses .env.integration-test which sets:
* - HAPI_HOME=~/.hapi-dev-test (DIFFERENT from dev's ~/.hapi-dev!)
* - HAPI_API_URL=http://localhost:3006 (local hapi-server)
* - CLI_API_TOKEN=... (must match the server)
* - HAPI_API_URL=http://localhost:3006 (local hapi-hub)
* - CLI_API_TOKEN=... (must match the hub)
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
@@ -48,7 +48,7 @@ async function waitFor(
throw new Error('Timeout waiting for condition');
}
// Check if dev server is running and properly configured
// Check if dev hub is running and properly configured
async function isServerHealthy(): Promise<boolean> {
try {
if (!configuration.cliApiToken) {
+2 -2
View File
@@ -172,8 +172,8 @@ export function getTunwgPath(): string {
return join(runtimePath(), 'tools', 'tunwg', tunwgBinary);
}
// Development mode: use downloaded binary from server/tools/tunwg
// Development mode: use downloaded binary from hub/tools/tunwg
const platformDir = getPlatformDir();
const devBinaryName = isWin ? `tunwg-${platformDir}.exe` : `tunwg-${platformDir}`;
return join(__dirname, '..', '..', '..', 'server', 'tools', 'tunwg', devBinaryName);
return join(__dirname, '..', '..', '..', 'hub', 'tools', 'tunwg', devBinaryName);
}
+6 -6
View File
@@ -4,7 +4,7 @@ import difftasticArchiveLicense from '../../tools/archives/difftastic-LICENSE' a
import ripgrepArchiveLicense from '../../tools/archives/ripgrep-LICENSE' assert { type: 'file' };
import difftasticLicense from '../../tools/licenses/difftastic-LICENSE' assert { type: 'file' };
import ripgrepLicense from '../../tools/licenses/ripgrep-LICENSE' assert { type: 'file' };
import tunwgLicense from '../../../server/tools/tunwg/LICENSE' assert { type: 'file' };
import tunwgLicense from '../../../hub/tools/tunwg/LICENSE' assert { type: 'file' };
export interface EmbeddedAsset {
relativePath: string;
@@ -35,7 +35,7 @@ async function selectEmbeddedAssets(): Promise<EmbeddedAsset[]> {
] = await Promise.all([
import('../../tools/archives/difftastic-arm64-darwin.tar.gz', { assert: { type: 'file' } }),
import('../../tools/archives/ripgrep-arm64-darwin.tar.gz', { assert: { type: 'file' } }),
import('../../../server/tools/tunwg/tunwg-arm64-darwin', { assert: { type: 'file' } })
import('../../../hub/tools/tunwg/tunwg-arm64-darwin', { assert: { type: 'file' } })
]);
return [
...COMMON_ASSETS,
@@ -53,7 +53,7 @@ async function selectEmbeddedAssets(): Promise<EmbeddedAsset[]> {
] = await Promise.all([
import('../../tools/archives/difftastic-x64-darwin.tar.gz', { assert: { type: 'file' } }),
import('../../tools/archives/ripgrep-x64-darwin.tar.gz', { assert: { type: 'file' } }),
import('../../../server/tools/tunwg/tunwg-x64-darwin', { assert: { type: 'file' } })
import('../../../hub/tools/tunwg/tunwg-x64-darwin', { assert: { type: 'file' } })
]);
return [
...COMMON_ASSETS,
@@ -71,7 +71,7 @@ async function selectEmbeddedAssets(): Promise<EmbeddedAsset[]> {
] = await Promise.all([
import('../../tools/archives/difftastic-arm64-linux.tar.gz', { assert: { type: 'file' } }),
import('../../tools/archives/ripgrep-arm64-linux.tar.gz', { assert: { type: 'file' } }),
import('../../../server/tools/tunwg/tunwg-arm64-linux', { assert: { type: 'file' } })
import('../../../hub/tools/tunwg/tunwg-arm64-linux', { assert: { type: 'file' } })
]);
return [
...COMMON_ASSETS,
@@ -89,7 +89,7 @@ async function selectEmbeddedAssets(): Promise<EmbeddedAsset[]> {
] = await Promise.all([
import('../../tools/archives/difftastic-x64-linux.tar.gz', { assert: { type: 'file' } }),
import('../../tools/archives/ripgrep-x64-linux.tar.gz', { assert: { type: 'file' } }),
import('../../../server/tools/tunwg/tunwg-x64-linux', { assert: { type: 'file' } })
import('../../../hub/tools/tunwg/tunwg-x64-linux', { assert: { type: 'file' } })
]);
return [
...COMMON_ASSETS,
@@ -107,7 +107,7 @@ async function selectEmbeddedAssets(): Promise<EmbeddedAsset[]> {
] = await Promise.all([
import('../../tools/archives/difftastic-x64-win32.tar.gz', { assert: { type: 'file' } }),
import('../../tools/archives/ripgrep-x64-win32.tar.gz', { assert: { type: 'file' } }),
import('../../../server/tools/tunwg/tunwg-x64-win32.exe', { assert: { type: 'file' } })
import('../../../hub/tools/tunwg/tunwg-x64-win32.exe', { assert: { type: 'file' } })
]);
return [
...COMMON_ASSETS,
+20 -20
View File
@@ -1,10 +1,10 @@
/**
* Auto-start server module
* Auto-start hub module
*
* Automatically starts the HAPI server when CLI is launched
* Automatically starts the HAPI hub when CLI is launched
* if specific conditions are met:
* 1. HAPI_API_URL is not set (using default localhost:3006)
* 2. cliApiToken exists in settings.json (server was previously started)
* 2. cliApiToken exists in settings.json (hub was previously started)
* 3. Port 3006 is not currently listening
*/
@@ -52,7 +52,7 @@ async function checkPortListening(port: number, host: string = '127.0.0.1'): Pro
}
/**
* Check if server is ready via health endpoint
* Check if hub is ready via health endpoint
*/
async function checkServerHealth(url: string): Promise<boolean> {
try {
@@ -66,7 +66,7 @@ async function checkServerHealth(url: string): Promise<boolean> {
}
/**
* Wait for server to become ready
* Wait for hub to become ready
*/
async function waitForServerReady(
url: string,
@@ -87,7 +87,7 @@ async function waitForServerReady(
}
/**
* Determine if server should be auto-started
* Determine if hub should be auto-started
*/
async function shouldAutoStartServer(): Promise<boolean> {
// Condition 1: HAPI_API_URL not set (using default localhost:3006)
@@ -99,13 +99,13 @@ async function shouldAutoStartServer(): Promise<boolean> {
// Condition 2: Check settings.json
const settings = await readSettings()
// 2a: apiUrl is set in settings.json (user configured a specific server)
// 2a: apiUrl is set in settings.json (user configured a specific hub)
if (settings.apiUrl || settings.serverUrl) {
logger.debug('[AUTO-START] apiUrl is set in settings.json, skipping auto-start')
return false
}
// 2b: cliApiToken exists in settings.json (server was previously started)
// 2b: cliApiToken exists in settings.json (hub was previously started)
if (!settings.cliApiToken) {
logger.debug('[AUTO-START] No cliApiToken in settings, skipping auto-start')
return false
@@ -122,25 +122,25 @@ async function shouldAutoStartServer(): Promise<boolean> {
}
/**
* Start server as a child process (will exit when CLI exits)
* Start hub as a child process (will exit when CLI exits)
*/
function startServerAsChild(): void {
const serverProcess = spawnHappyCLI(['server'], {
const serverProcess = spawnHappyCLI(['hub'], {
detached: false,
stdio: 'ignore',
env: process.env
})
logger.debug(`[AUTO-START] Server process spawned with PID ${serverProcess.pid}`)
logger.debug(`[AUTO-START] Hub process spawned with PID ${serverProcess.pid}`)
// Ensure server is killed when CLI exits
// Ensure hub is killed when CLI exits
process.on('exit', () => {
serverProcess.kill()
})
}
/**
* Main entry point: auto-start server if conditions are met
* Main entry point: auto-start hub if conditions are met
*/
export async function maybeAutoStartServer(): Promise<void> {
try {
@@ -149,23 +149,23 @@ export async function maybeAutoStartServer(): Promise<void> {
return
}
logger.debug('[AUTO-START] Starting server automatically...')
console.log(chalk.gray('Starting HAPI server in background...'))
logger.debug('[AUTO-START] Starting hub automatically...')
console.log(chalk.gray('Starting HAPI hub in background...'))
startServerAsChild()
const isReady = await waitForServerReady(configuration.apiUrl)
if (!isReady) {
console.log(chalk.yellow('Warning: Server did not start within expected time'))
console.log(chalk.gray(' Try running `hapi server` manually to see errors'))
console.log(chalk.yellow('Warning: Hub did not start within expected time'))
console.log(chalk.gray(' Try running `hapi hub` manually to see errors'))
return
}
console.log(chalk.green('HAPI server started'))
console.log(chalk.green('HAPI hub started'))
} catch (error) {
logger.debug('[AUTO-START] Error during server auto-start', error)
console.log(chalk.yellow('Warning: Failed to auto-start server'))
logger.debug('[AUTO-START] Error during hub auto-start', error)
console.log(chalk.yellow('Warning: Failed to auto-start hub'))
if (error instanceof Error) {
console.log(chalk.gray(` Error: ${error.message}`))
}
+2 -2
View File
@@ -27,7 +27,7 @@
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.d.ts",
"../server/src/**/*.ts",
"../server/src/**/*.d.ts"
"../hub/src/**/*.ts",
"../hub/src/**/*.d.ts"
]
}