hermes-kanban-miniapp/packages/bridge/src/hermes-client.ts
kisskin ade88808fb
Some checks failed
CI / backend (push) Failing after 37s
CI / frontend (push) Failing after 21s
feat: M4 docker deployment - bridge + frontend containers
- packages/bridge/Dockerfile: multi-stage build (node:22-alpine)
- frontend/Dockerfile: multi-stage build + nginx:alpine
- frontend/nginx.conf: proxy /bridge to bridge service
- docker-compose.yml: bridge (3456) + frontend (8080)
- .dockerignore: exclude backend/node_modules/dist
- Fix vite proxy target to port 3456
- .env with bridge vars (AUTH_MODE=dev)
2026-07-10 14:58:20 +03:00

211 lines
6.4 KiB
TypeScript

// ─────────────────────────────────────────────────────────────
// Hermes CLI client — type-safe wrapper for `hermes kanban ...`
// ADR §3.5: "uses stable hermes kanban ... --json CLI commands first"
// ADR §3.5: "fixed argv and shell: false"
// ─────────────────────────────────────────────────────────────
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import type {
Board,
TaskSummary,
TaskDetail,
} from '@kanban/contracts';
const execFile = promisify(execFileCb);
// ─── Types ───────────────────────────────────────────────────
export interface HermesClientConfig {
/** Path to hermes binary (default: 'hermes') */
bin: string;
/** HERMES_HOME for target profile */
home: string;
/** Default board slug */
defaultBoard: string;
/** Per-command timeout in ms */
timeoutMs: number;
}
// ─── Allowed commands ────────────────────────────────────────
/**
* Command allowlist. Each entry defines:
* - subcommand: the `hermes kanban <sub>` string
* - requiredArgs: positional args the caller must provide
* - optionalFlags: flags that may be appended
*
* This prevents arbitrary pass-through to the hermes CLI.
*/
type KanbanSubcommand =
| 'boards'
| 'list'
| 'show'
| 'create'
| 'comment'
| 'assign'
| 'promote'
| 'block'
| 'unblock'
| 'archive'
| 'dispatch'
| 'log';
const ALLOWED_SUBCOMMANDS = new Set<KanbanSubcommand>([
'boards',
'list',
'show',
'create',
'comment',
'assign',
'promote',
'block',
'unblock',
'archive',
'dispatch',
'log',
]);
// ─── Client ──────────────────────────────────────────────────
export class HermesKanbanClient {
private readonly config: HermesClientConfig;
constructor(config: HermesClientConfig) {
this.config = config;
}
/**
* Execute a hermes kanban subcommand with --json output.
* Uses execFile (shell: false) for safety.
*/
async exec<T>(
subcommand: KanbanSubcommand,
args: string[],
board?: string
): Promise<T> {
if (!ALLOWED_SUBCOMMANDS.has(subcommand)) {
throw new Error(`Blocked disallowed subcommand: ${subcommand}`);
}
const fullArgs = [
'kanban',
subcommand,
...args,
'--board', board ?? this.config.defaultBoard,
'--json',
];
const env = {
...process.env,
HERMES_HOME: this.config.home,
};
try {
const { stdout, stderr } = await execFile(this.config.bin, fullArgs, {
shell: false, // CRITICAL: never use shell
timeout: this.config.timeoutMs,
env,
maxBuffer: 5 * 1024 * 1024, // 5MB
});
if (stderr) {
console.warn(`[hermes-client] stderr: ${redact(stderr)}`);
}
return JSON.parse(stdout) as T;
} catch (err) {
if (err instanceof Error) {
// Redact any secrets that might leak into error messages
throw new Error(`Hermes CLI error: ${redact(err.message)}`);
}
throw err;
}
}
// ─── Convenience methods (M2 will flesh these out) ────────
async listBoards(): Promise<Board[]> {
return this.exec<Board[]>('boards', ['list']);
}
async listTasks(
board: string,
opts?: { status?: string; assignee?: string; limit?: number }
): Promise<TaskSummary[]> {
const args: string[] = [];
if (opts?.status) args.push('--status', opts.status);
if (opts?.assignee) args.push('--assignee', opts.assignee);
if (opts?.limit) args.push('--limit', String(opts.limit));
return this.exec<TaskSummary[]>('list', args, board);
}
async getTask(board: string, taskId: string): Promise<TaskDetail> {
return this.exec<TaskDetail>('show', [taskId], board);
}
async createTask(
board: string,
payload: { title: string; body?: string; assignee?: string; priority?: number }
): Promise<TaskDetail> {
const args = ['--title', payload.title];
if (payload.body) args.push('--body', payload.body);
if (payload.assignee) args.push('--assignee', payload.assignee);
if (payload.priority !== undefined) args.push('--priority', String(payload.priority));
return this.exec<TaskDetail>('create', args, board);
}
async addComment(board: string, taskId: string, body: string): Promise<void> {
await this.exec('comment', [taskId, '--body', body], board);
}
async assignTask(board: string, taskId: string, assignee: string): Promise<void> {
await this.exec('assign', [taskId, '--assignee', assignee], board);
}
async promoteTask(board: string, taskId: string, reason?: string): Promise<void> {
const args = [taskId];
if (reason) args.push('--reason', reason);
await this.exec('promote', args, board);
}
async blockTask(board: string, taskId: string, reason: string): Promise<void> {
await this.exec('block', [taskId, '--reason', reason], board);
}
async unblockTask(board: string, taskId: string): Promise<void> {
await this.exec('unblock', [taskId], board);
}
async archiveTask(board: string, taskId: string): Promise<void> {
await this.exec('archive', [taskId], board);
}
async dispatchBoard(board: string): Promise<void> {
await this.exec('dispatch', [], board);
}
async getTaskLog(board: string, taskId: string, lines?: number): Promise<string> {
const args = [taskId];
if (lines) args.push('--lines', String(lines));
const result = await this.exec<{ content?: string; log?: string }>('log', args, board);
return result.content ?? result.log ?? '';
}
}
// ─── Redaction helper ────────────────────────────────────────
const SENSITIVE_PATTERNS = [
/token[=:]\s*\S+/gi,
/secret[=:]\s*\S+/gi,
/password[=:]\s*\S+/gi,
/api[_-]?key[=:]\s*\S+/gi,
];
function redact(text: string): string {
let result = text;
for (const pattern of SENSITIVE_PATTERNS) {
result = result.replace(pattern, '[REDACTED]');
}
return result;
}